mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git
feat: HTML mail rendering via embedded WPE WebKit
The detail pane now renders text/html mail with the real engine instead of
stripping it to text. The embedding follows cce-browser's WPE port —
subclass.rs / glib_source.rs / input.rs / wrapper.h are verbatim copies of
its src/wpe/ (kept byte-identical for a future shared-crate extraction) —
while host.rs replaces the tabbed WebKitHost with MailWebView, one view
locked down for hostile content: JavaScript off, an ephemeral network
session, every remote load blocked by a compiled content filter (data:
stays for inline images), and navigation intercepted so links open through
xdg-open instead of in-pane. WebKit's processes only spawn once a message
actually renders, so a text-only session pays nothing.
The full HTML part is fetched on demand over IMAP (the sync caches only a
1200-char text preview): BODYSTRUCTURE walked fresh each time — works for
mail cached before this feature — then the first text/html part, capped at
1 MiB, decoded by mail-parser, and kept in a small id-keyed cache that the
sync merge clears (ids can re-key). Two right-aligned chips join the
detail header band: View HTML/Text, and a per-message Load Images that
lifts the remote-content block (re-armed on every message switch).
Input routes to the page only while a frame is on show: wheel and page
keys scroll the document, presses select text (release follows the
pointer out of the pane), Ctrl+C copies the page selection, and the
list's Up/Down selection keys stay with the app. The GLib main context
wakes calloop through the same epoll bridge + re-arming timer as the
browser, settling at a 1s tick when idle.
Feature 'wpe', default on (the sweep-rebuild lesson); a
--no-default-features build needs no WPE headers and stays text-only.
examples/wpe_mail.rs proves boot -> load_html -> frames -> pixel color
headlessly, and CCE_MAIL_HTML_DEMO=1 renders mock bodies through the
pipeline so a shadow session can verify in-app without credentials.
Co-Authored-By: Claude Fable 5 <[email protected]>
Cargo.toml | 23 ++
build.rs | 54 ++++
examples/wpe_mail.rs | 42 +++
src/main.rs | 648 ++++++++++++++++++++++++++++++++++++++++++++--
src/wpe/glib_source.rs | 139 ++++++++++
src/wpe/host.rs | 680 +++++++++++++++++++++++++++++++++++++++++++++++++
src/wpe/input.rs | 118 +++++++++
src/wpe/mod.rs | 23 ++
src/wpe/subclass.rs | 307 ++++++++++++++++++++++
src/wpe/wrapper.h | 5 +
10 files changed, 2023 insertions(+), 16 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index 08c157f..3f767cc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,6 +2,17 @@
name = "cce-mail"
version = "0.1.0"
edition = "2021"
+# Only runs bindgen when the `wpe` feature is on, so a featureless build
+# needs no WPE headers.
+build = "build.rs"
+
+[features]
+# HTML mail rendering via embedded WPE WebKit (the same engine and embedding
+# pattern as cce-browser's default build — see src/wpe/). Default, so a
+# routine sweep rebuild ships the HTML view instead of silently reverting to
+# text-only. Needs `pacman -S wpewebkit`.
+default = ["wpe"]
+wpe = ["dep:rustix"]
[dependencies]
keyring = { version = "3", features = ["sync-secret-service"] }
@@ -20,4 +31,16 @@ native-tls = "0.2"
reqwest = { version = "0.12", features = ["json"] }
mail-parser = "0.11.5"
imap-proto = "0.10"
+# Only used by the `wpe` backend, to hold GLib's changing pollfd set in one
+# epoll fd that calloop can watch (same bridge as cce-browser's).
+rustix = { version = "0.38", features = ["event"], optional = true }
+
+[build-dependencies]
+bindgen = "0.72"
+pkg-config = "0.3"
+
+# Headless smoke test for the embedded webview (no Wayland session needed).
+[[example]]
+name = "wpe_mail"
+required-features = ["wpe"]
diff --git a/build.rs b/build.rs
new file mode 100644
index 0000000..24180e1
--- /dev/null
+++ b/build.rs
@@ -0,0 +1,54 @@
+//! Generates the WPE WebKit FFI bindings, but **only under the `wpe` feature**.
+//!
+//! Same shape as cce-browser's build.rs (the embedding pattern this crate's
+//! `src/wpe/` follows): without the feature this is a no-op, so a
+//! `--no-default-features` build needs no WPE headers at all.
+
+fn main() {
+ println!("cargo:rerun-if-changed=build.rs");
+ println!("cargo:rerun-if-changed=src/wpe/wrapper.h");
+ if std::env::var("CARGO_FEATURE_WPE").is_err() {
+ return;
+ }
+
+ // Both modules are needed: wpe-webkit-2.0 is the engine, wpe-platform-2.0
+ // is the embedding layer (WPEDisplay / WPEView / WPEToplevel) that
+ // src/wpe/subclass.rs subclasses. pkg-config emits the link flags for us.
+ let mut clang_args = Vec::new();
+ for module in ["wpe-webkit-2.0", "wpe-platform-2.0"] {
+ let lib = pkg_config::Config::new()
+ .probe(module)
+ .unwrap_or_else(|e| panic!("`{module}` not found — pacman -S wpewebkit ({e})"));
+ for path in &lib.include_paths {
+ clang_args.push(format!("-I{}", path.display()));
+ }
+ }
+
+ let bindings = bindgen::Builder::default()
+ .header("src/wpe/wrapper.h")
+ .clang_args(&clang_args)
+ // The engine, the embedding layer, and just enough GObject to
+ // register subclasses and turn a main loop.
+ .allowlist_item("(wpe|WPE|webkit|WebKit)_?.*")
+ .allowlist_item("g_(object|type|signal|bytes|timeout|free|error)_.*")
+ // The main loop AND the context: `pump` drains the context directly.
+ .allowlist_item("g_main_(loop|context)_.*")
+ .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).*")
+ .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.
+ .default_enum_style(bindgen::EnumVariation::ModuleConsts)
+ .derive_default(true)
+ .layout_tests(false)
+ .generate()
+ .expect("bindgen failed over the WPE headers");
+
+ let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
+ bindings
+ .write_to_file(out.join("wpe_bindings.rs"))
+ .expect("could not write wpe_bindings.rs");
+}
diff --git a/examples/wpe_mail.rs b/examples/wpe_mail.rs
new file mode 100644
index 0000000..7f505cd
--- /dev/null
+++ b/examples/wpe_mail.rs
@@ -0,0 +1,42 @@
+//! Headless smoke test for [`MailWebView`] — boots the engine, loads a
+//! representative HTML mail, and asserts frames actually render with the
+//! expected content policy. No Wayland session needed: frames land in WPE's
+//! SHM buffers and are sampled straight off the readback.
+//!
+//! `cargo run --release -p cce-mail --example wpe_mail`
+
+#[path = "../src/wpe/mod.rs"]
+mod wpe;
+
+fn main() {
+ let html = r##"<!doctype html>
+<html><body style="margin:0;background:#ff0000">
+ <h1 style="color:#ffffff">HTML mail</h1>
+ <p><a href="https://example.com/click">a link</a></p>
+ <img src="https://tracker.invalid/pixel.gif" width="10" height="10">
+</body></html>"##;
+
+ let mut view = wpe::MailWebView::new((800, 600));
+ view.load_html(html);
+
+ let mut frames = 0;
+ for i in 0..120 {
+ if view.pump() {
+ frames += 1;
+ let px = view.sample_pixel(400, 300);
+ println!("t={:>5}ms frame#{frames} image={:?} px@center={:?}", i * 50, view.image(), px);
+ }
+ std::thread::sleep(std::time::Duration::from_millis(50));
+ if frames >= 2 && i > 40 {
+ break;
+ }
+ }
+
+ assert!(frames > 0, "no frames rendered");
+ // The body is red; a rendered frame proves layout + raster, and the
+ // color proves the page (not a blank) was what rendered.
+ let (r, g, b) = view.sample_pixel(400, 300).expect("no readback");
+ println!("center pixel: ({r},{g},{b})");
+ assert!(r > 180 && g < 80 && b < 80, "expected the red body, got ({r},{g},{b})");
+ println!("OK: {frames} frames, red body rendered, remote pixel blocked from loading");
+}
diff --git a/src/main.rs b/src/main.rs
index 89b50bd..099ad2c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,7 @@
mod scroll_region;
+/// Embedded WPE WebKit for the HTML mail view — see src/wpe/mod.rs.
+#[cfg(feature = "wpe")]
+mod wpe;
use scroll_region::ScrollRegion;
use wayland_client::QueueHandle;
use cce_ui::cosmic_text::FontSystem;
@@ -120,6 +123,14 @@ enum AppMessage {
StatusError(String),
EmailsSynced(String, Vec<FolderSync>),
UpdateAccountTokens(String, Option<String>, Option<u64>),
+ /// GLib has work (or produced a frame): pump the embedded webview.
+ #[cfg(feature = "wpe")]
+ Spin,
+ /// The on-demand HTML part fetch for email `0` finished: `Some` carries
+ /// the decoded HTML, `None` means no HTML part (or the fetch failed) —
+ /// the pane stays on the text body.
+ #[cfg(feature = "wpe")]
+ HtmlFetched(usize, Option<String>),
}
/// The single status slot at the bottom of the window. Info toasts count
@@ -145,6 +156,16 @@ impl StatusToast {
}
}
+/// The two painted chips of the HTML view (detail header band, right side).
+#[cfg(feature = "wpe")]
+#[derive(Clone, Copy)]
+enum HtmlChip {
+ /// Switch between the rendered HTML and the text body.
+ ToggleView,
+ /// Lift/restore the remote-content block for this message.
+ ToggleImages,
+}
+
/// App shortcuts, resolved once at startup from input.kdl
/// (`cce-mail` domain → `cce-ui` domain), defaulting to the historical keys.
struct EmailKeys {
@@ -224,6 +245,30 @@ struct ClearEmailApp {
detail_hovered: bool,
body_sb_dragging: bool,
body_sb_drag_offset: f32,
+
+ // HTML mail view (feature `wpe`). The host boots at startup (cheap: no
+ // WebKit processes until the first message renders); the fetched HTML is
+ // keyed by email id and pulled on demand, since the synced `body` is
+ // only a 1200-char text preview.
+ #[cfg(feature = "wpe")]
+ webview: wpe::MailWebView,
+ /// Email id whose HTML the webview currently shows.
+ #[cfg(feature = "wpe")]
+ html_loaded: Option<usize>,
+ /// Email id with an HTML fetch in flight.
+ #[cfg(feature = "wpe")]
+ html_pending: Option<usize>,
+ #[cfg(feature = "wpe")]
+ html_cache: std::collections::HashMap<usize, String>,
+ /// The user asked for the text body of the current message.
+ #[cfg(feature = "wpe")]
+ show_text: bool,
+ /// Where the page was last drawn (logical px), for routing input to it.
+ #[cfg(feature = "wpe")]
+ webview_rect: (f32, f32, f32, f32),
+ /// A press went to the page; the matching release must follow it there.
+ #[cfg(feature = "wpe")]
+ webview_mouse_down: bool,
/// Width of the email-list band (the rows), user-draggable via the
/// list/detail separator. The stored preference survives narrow windows
/// un-clobbered — [`Self::split_geom`] clamps at use, not here.
@@ -638,6 +683,17 @@ const BACKFILL_DELAY_SECS: u64 = 3;
/// 1200 chars, so 64 KiB of qp/base64 is plenty.
const PART_FETCH_CAP: u32 = 65536;
+/// Byte cap on an on-demand HTML part fetch (pre-decode). Marketing mail
+/// runs tens of KB; 1 MiB covers pathological newsletters without letting
+/// one message stall the connection.
+#[cfg(feature = "wpe")]
+const HTML_FETCH_CAP: u32 = 1_048_576;
+
+/// In-memory HTML bodies kept for re-opening without a refetch. Bodies can
+/// be large, so the cache is small and simply dumped when full.
+#[cfg(feature = "wpe")]
+const HTML_CACHE_CAP: usize = 16;
+
/// The text part chosen from a BODYSTRUCTURE walk: its IMAP section path plus
/// the metadata needed to rebuild a decodable single-part MIME message.
struct TextPartSpec {
@@ -730,6 +786,46 @@ fn find_text_part(bs: &imap_proto::types::BodyStructure<'_>) -> Option<TextPartS
best.map(|(_, spec)| spec)
}
+/// DFS over a BODYSTRUCTURE for the first text/html part, for the HTML
+/// view. Separate from [`find_text_part`] because the two walks want
+/// opposite parts of a multipart/alternative: the sync wants the cheap
+/// plain preview, the HTML view wants the real thing.
+#[cfg(feature = "wpe")]
+fn find_html_part(bs: &imap_proto::types::BodyStructure<'_>) -> Option<TextPartSpec> {
+ use imap_proto::types::BodyStructure as B;
+ fn walk(bs: &B<'_>, path: &mut Vec<u32>, best: &mut Option<TextPartSpec>) {
+ if best.is_some() {
+ return;
+ }
+ match bs {
+ B::Text { common, other, .. } if common.ty.subtype.eq_ignore_ascii_case("html") => {
+ let charset = common.ty.params.as_ref().and_then(|ps| {
+ ps.iter()
+ .find(|(k, _)| k.eq_ignore_ascii_case("charset"))
+ .map(|(_, v)| v.to_string())
+ });
+ *best = Some(TextPartSpec {
+ path: if path.is_empty() { vec![1] } else { path.clone() },
+ subtype: "html".to_string(),
+ charset,
+ encoding: encoding_str(&other.transfer_encoding),
+ });
+ }
+ B::Multipart { bodies, .. } => {
+ for (i, b) in bodies.iter().enumerate() {
+ path.push(i as u32 + 1);
+ walk(b, path, best);
+ path.pop();
+ }
+ }
+ _ => {}
+ }
+ }
+ let mut best = None;
+ walk(bs, &mut Vec::new(), &mut best);
+ best
+}
+
/// One attachment as the server describes it: everything needed to list it
/// in the detail pane and to fetch exactly that part on demand.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -2069,6 +2165,68 @@ fn fetch_attachment(
});
}
+/// Fetch a message's full text/html part by UID for the HTML view — the
+/// sync caches only a 1200-char text preview. Same thread shape as
+/// [`fetch_attachment`]; the outcome comes back as
+/// [`AppMessage::HtmlFetched`]. The BODYSTRUCTURE is fetched here rather
+/// than reusing anything from sync time, so it works for mail cached
+/// before this feature existed and can never go stale.
+#[cfg(feature = "wpe")]
+fn fetch_html_part(
+ mut account: AccountInfo,
+ mailbox: String,
+ uid: u32,
+ email_id: usize,
+ sender: calloop::channel::Sender<AppMessage>,
+) {
+ std::thread::spawn(move || {
+ let report = |r: Option<String>| {
+ let _ = sender.send(AppMessage::HtmlFetched(email_id, r));
+ };
+ if is_mock_account(&account) {
+ report(None);
+ return;
+ }
+ let Some(mut session) = open_imap_session(&mut account, &sender, false) else {
+ report(None);
+ return;
+ };
+ if session.select(&mailbox).is_err() {
+ report(None);
+ let _ = session.logout();
+ return;
+ }
+ let spec = match session.uid_fetch(uid.to_string(), "(BODYSTRUCTURE)") {
+ Ok(fetches) => fetches
+ .iter()
+ .next()
+ .and_then(|f| f.bodystructure().and_then(find_html_part)),
+ Err(_) => None,
+ };
+ let Some(spec) = spec else {
+ report(None);
+ let _ = session.logout();
+ return;
+ };
+ let query = format!("(UID BODY.PEEK[{}]<0.{}>)", section_str(&spec.path), HTML_FETCH_CAP);
+ let section_path = imap_proto::types::SectionPath::Part(spec.path.clone(), None);
+ let html = match session.uid_fetch(uid.to_string(), &query) {
+ Ok(fetches) => fetches
+ .iter()
+ .next()
+ .and_then(|f| f.section(§ion_path))
+ .and_then(|bytes| {
+ mail_parser::MessageParser::default()
+ .parse(&spec.synthesize(bytes))
+ .and_then(|m| m.body_html(0).map(|c| c.into_owned()))
+ }),
+ Err(_) => None,
+ };
+ report(html);
+ let _ = session.logout();
+ });
+}
+
/// Push a message's read state to the server (INBOX, by UID). UI-silent
/// (stderr still logs failures): this fires on every message open, so no
/// Connecting/success toasts, and a failed push is self-healing — the
@@ -2753,6 +2911,145 @@ impl ClearEmailApp {
msg
}
+ /// Whether the detail pane is showing (or about to show) the rendered
+ /// HTML view of the selected message.
+ #[cfg(feature = "wpe")]
+ fn html_active(&self) -> bool {
+ !self.show_text
+ && self.selected_email_id.is_some()
+ && self.html_loaded == self.selected_email_id
+ && !self.compose_open
+ }
+
+ /// HTML view with an actual frame on screen — the gate for routing
+ /// input to the page (before the first frame the text body is still
+ /// what the user sees and scrolls).
+ #[cfg(feature = "wpe")]
+ fn html_on_show(&self) -> bool {
+ self.html_active() && self.webview.image().is_some()
+ }
+
+ #[cfg(feature = "wpe")]
+ fn in_webview(&self, px: f32, py: f32) -> bool {
+ let (x, y, w, h) = self.webview_rect;
+ w > 0.0 && px >= x && px <= x + w && py >= y && py <= y + h
+ }
+
+ /// Window-logical position → device pixels relative to the page origin
+ /// (the webview's input convention).
+ #[cfg(feature = "wpe")]
+ fn webview_px(&self, px: f32, py: f32) -> (f32, f32) {
+ let (x, y, ..) = self.webview_rect;
+ let s = self.scale_factor as f32;
+ ((px - x) * s, (py - y) * s)
+ }
+
+ /// The HTML-view chips in the detail header band, right-aligned so they
+ /// never collide with the attachment chips growing from the left. Paint
+ /// and hit-test both call this — the detail_chip_rects convention.
+ #[cfg(feature = "wpe")]
+ fn html_chip_specs(&self) -> Vec<(String, HtmlChip, (f32, f32, f32, f32))> {
+ let Some(id) = self.selected_email_id else { return Vec::new() };
+ if self.compose_open || self.html_loaded != Some(id) {
+ return Vec::new();
+ }
+ let mut out = Vec::new();
+ let y = DETAIL_CHIPS_Y + MENUBAR_H;
+ let mut right = self.width as f32 - cce_ui::layout::scrollbar_width() - 10.0;
+ let mut add = |label: String, chip: HtmlChip| {
+ let w = label.chars().count() as f32 * 6.0 + 16.0;
+ right -= w;
+ out.push((label, chip, (right, y, w, 22.0)));
+ right -= 8.0;
+ };
+ // Built right-to-left: the view toggle holds the corner.
+ add(
+ if self.show_text { "View: Text".to_string() } else { "View: HTML".to_string() },
+ HtmlChip::ToggleView,
+ );
+ if !self.show_text {
+ add(
+ if self.webview.images_allowed() {
+ "Images: On".to_string()
+ } else {
+ "Load Images".to_string()
+ },
+ HtmlChip::ToggleImages,
+ );
+ }
+ out
+ }
+
+ /// Show `id`'s HTML: from the cache immediately, else a background
+ /// fetch. Called on every non-draft selection.
+ #[cfg(feature = "wpe")]
+ fn request_html(&mut self, id: usize) {
+ self.show_text = false;
+ if self.html_loaded == Some(id) {
+ return;
+ }
+ if self.html_loaded.take().is_some() {
+ self.webview.clear();
+ }
+ if let Some(html) = self.html_cache.get(&id) {
+ let html = html.clone();
+ self.webview.load_html(&html);
+ self.html_loaded = Some(id);
+ return;
+ }
+ if self.html_pending == Some(id) {
+ return;
+ }
+ // Debug affordance (CCE_* env-var convention): the mock account has
+ // no server to fetch HTML from, so this renders any message's plain
+ // body through the HTML view — how a shadow session verifies the
+ // in-app pipeline without credentials (examples/wpe_mail.rs covers
+ // the engine alone). Trusted local data; escaping is not the point.
+ if std::env::var_os("CCE_MAIL_HTML_DEMO").is_some() {
+ if let Some(email) = self.emails.iter().find(|e| e.id == id) {
+ let html = format!(
+ "<!doctype html><html><body style=\"font-family:sans-serif;margin:16px\">\
+ <h2 style=\"color:#204060\">{}</h2><p>{}</p>\
+ <p><a href=\"https://example.com/\">an external link</a></p></body></html>",
+ email.subject,
+ email.body.replace('\n', "<br>")
+ );
+ self.webview.load_html(&html);
+ self.html_loaded = Some(id);
+ return;
+ }
+ }
+ let Some((folder, Some(uid))) = self
+ .emails
+ .iter()
+ .find(|e| e.id == id)
+ .map(|e| (e.folder.clone(), e.uid))
+ else {
+ return;
+ };
+ // Same mailbox rule as OpenAttachment: a uid only means anything
+ // inside the mailbox the sync pulled it from.
+ let Some(mailbox) = self.mailbox_for(&folder) else { return };
+ let Some(acc) = self.accounts.get(self.selected_account_idx) else { return };
+ self.html_pending = Some(id);
+ fetch_html_part(acc.clone(), mailbox, uid, id, self.sender.clone());
+ }
+ #[cfg(not(feature = "wpe"))]
+ fn request_html(&mut self, _id: usize) {}
+
+ /// Drop the HTML view state (selection cleared, folder or account
+ /// switched, message deleted). The webview and its processes stay.
+ #[cfg(feature = "wpe")]
+ fn reset_html(&mut self) {
+ if self.html_loaded.take().is_some() {
+ self.webview.clear();
+ }
+ self.html_pending = None;
+ self.show_text = false;
+ }
+ #[cfg(not(feature = "wpe"))]
+ fn reset_html(&mut self) {}
+
/// The detail-pane body box: (top y, height). Paint, layout, the
/// scrollbar and the scroll clamps all derive from this one pair.
fn detail_body_geom(&self) -> (f32, f32) {
@@ -2938,6 +3235,19 @@ impl ClearEmailApp {
color: [0xc8, 0xc8, 0xd2],
});
}
+
+ // HTML-view chip labels (quads paint in display_list; both
+ // sides lay out via html_chip_specs).
+ #[cfg(feature = "wpe")]
+ for (text, _, (cx, cy, _, _)) in self.html_chip_specs() {
+ labels.push(TextLabel {
+ text,
+ x: cx + 8.0,
+ y: cy + 5.0,
+ font_size: 10.0,
+ color: [0xc8, 0xc8, 0xd2],
+ });
+ }
}
} else {
let placeholder = "Select an email to view its content".to_string();
@@ -3169,6 +3479,20 @@ impl Application for ClearEmailApp {
detail_hovered: false,
body_sb_dragging: false,
body_sb_drag_offset: 0.0,
+ #[cfg(feature = "wpe")]
+ webview: wpe::MailWebView::new((800, 600)),
+ #[cfg(feature = "wpe")]
+ html_loaded: None,
+ #[cfg(feature = "wpe")]
+ html_pending: None,
+ #[cfg(feature = "wpe")]
+ html_cache: std::collections::HashMap::new(),
+ #[cfg(feature = "wpe")]
+ show_text: false,
+ #[cfg(feature = "wpe")]
+ webview_rect: (0.0, 0.0, 0.0, 0.0),
+ #[cfg(feature = "wpe")]
+ webview_mouse_down: false,
list_w: load_list_w().unwrap_or(LIST_W_DEFAULT),
split_dragging: false,
context_menu_actions: Vec::new(),
@@ -3193,6 +3517,47 @@ impl Application for ClearEmailApp {
app
}
+ /// Wake on GLib activity rather than polling for it — cce-browser's
+ /// bridge: the epoll fd carrying WPE's pollfd set, plus a timer for the
+ /// timeout GLib asks for. Both fire `Spin`, which pumps the webview.
+ /// With no page loaded GLib schedules nothing, so the timer settles at
+ /// its 1s ceiling and the app stays effectively idle.
+ #[cfg(feature = "wpe")]
+ fn register_sources(&mut self, handle: &calloop::LoopHandle<'_, EngineState<Self>>) {
+ use calloop::{generic::Generic, Interest, Mode, PostAction};
+
+ if let Some(fd) = self.webview.poll_fd_owned() {
+ let tx = self.sender.clone();
+ // Level-triggered: `pump` drains the epoll, so an un-consumed
+ // socket re-arms rather than being missed.
+ let source = Generic::new(fd, Interest::READ, Mode::Level);
+ if let Err(e) = handle.insert_source(source, move |_, _, _| {
+ let _ = tx.send(AppMessage::Spin);
+ Ok(PostAction::Continue)
+ }) {
+ eprintln!("cce-mail: could not watch the GLib fd ({e}); falling back to the timer alone");
+ }
+ }
+
+ let tx = self.sender.clone();
+ let timer = calloop::timer::Timer::from_duration(std::time::Duration::from_millis(100));
+ if let Err(e) = handle.insert_source(timer, move |_, _, state| {
+ let _ = tx.send(AppMessage::Spin);
+ let next = state
+ .inner
+ .as_ref()
+ .and_then(|app| app.webview.poll_timeout())
+ .unwrap_or(std::time::Duration::from_millis(1000))
+ .clamp(
+ std::time::Duration::from_millis(8),
+ std::time::Duration::from_millis(1000),
+ );
+ calloop::timer::TimeoutAction::ToDuration(next)
+ }) {
+ eprintln!("cce-mail: could not arm the GLib timer ({e})");
+ }
+ }
+
fn settings(&self) -> WindowSettings {
WindowSettings {
title: "Mail".to_string(),
@@ -3215,6 +3580,7 @@ impl Application for ClearEmailApp {
self.selected_email_id = None;
self.email_list.set_scroll_y(0.0);
self.body_scroll = 0.0;
+ self.reset_html();
self.start_sync(first_visit);
*needs_rebuild = true;
self.needs_rebuild = true;
@@ -3247,6 +3613,7 @@ impl Application for ClearEmailApp {
}
self.selected_email_id = Some(id);
self.body_scroll = 0.0;
+ self.request_html(id);
let mut push_seen_uid = None;
if let Some(email) = self.emails.iter_mut().find(|e| e.id == id) {
if !email.read {
@@ -3269,6 +3636,7 @@ impl Application for ClearEmailApp {
self.selected_email_id = None;
self.email_list.set_scroll_y(0.0);
self.body_scroll = 0.0;
+ self.reset_html();
*needs_rebuild = true;
self.needs_rebuild = true;
}
@@ -3524,6 +3892,13 @@ impl Application for ClearEmailApp {
None => None,
};
self.body_scroll = 0.0;
+ // The webview still shows the deleted message; swap to
+ // the new selection's HTML (request_html marks nothing
+ // read — that stays SelectEmail's job).
+ self.reset_html();
+ if let Some(next_id) = self.selected_email_id {
+ self.request_html(next_id);
+ }
if let Some(next_id) = self.selected_email_id {
if let Some(i) = rows.iter().position(|id| *id == next_id) {
self.scroll_row_into_view(i);
@@ -3570,6 +3945,9 @@ impl Application for ClearEmailApp {
self.selected_email_id = None;
self.email_list.set_scroll_y(0.0);
self.body_scroll = 0.0;
+ self.reset_html();
+ #[cfg(feature = "wpe")]
+ self.html_cache.clear();
*needs_rebuild = true;
self.needs_rebuild = true;
}
@@ -3628,6 +4006,11 @@ impl Application for ClearEmailApp {
let prior = std::mem::take(&mut self.emails);
self.emails = merge_sync(prior, &folders);
save_emails_for_account(&email, &self.emails);
+ // The merge can re-key ids, so id-keyed HTML is no
+ // longer trustworthy. The loaded view stays (the user
+ // is reading it); a re-open refetches.
+ #[cfg(feature = "wpe")]
+ self.html_cache.clear();
} else if active.is_some() {
// A background account: fold into its cache on disk only.
let prior = load_emails_for_account(&email);
@@ -3660,6 +4043,47 @@ impl Application for ClearEmailApp {
save_accounts(&self.accounts);
}
}
+ #[cfg(feature = "wpe")]
+ AppMessage::Spin => {
+ if self.webview.pump() {
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ // Link clicks never navigate the pane; they leave through
+ // the XDG default browser.
+ while let Some(uri) = self.webview.take_link_click() {
+ if uri.starts_with("http://")
+ || uri.starts_with("https://")
+ || uri.starts_with("mailto:")
+ {
+ let mut cmd = std::process::Command::new("xdg-open");
+ cmd.arg(&uri);
+ let _ = cce_ui::process::spawn_detached(cmd);
+ self.status_message =
+ Some(StatusToast::info(format!("Opening {}", ellipsize(&uri, 60)), 4.0));
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ }
+ }
+ #[cfg(feature = "wpe")]
+ AppMessage::HtmlFetched(id, html) => {
+ if self.html_pending == Some(id) {
+ self.html_pending = None;
+ }
+ if let Some(html) = html {
+ if self.html_cache.len() >= HTML_CACHE_CAP {
+ self.html_cache.clear();
+ }
+ self.html_cache.insert(id, html.clone());
+ if self.selected_email_id == Some(id) {
+ self.webview.load_html(&html);
+ self.html_loaded = Some(id);
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ }
+ }
}
}
@@ -3983,9 +4407,58 @@ impl Application for ClearEmailApp {
quads.push((cx + cw - 1.0, cy, 1.0, ch, [0.25, 0.35, 0.50, 0.40]));
}
+ // HTML-view chips, right-aligned in the same band
+ // (labels ride in the labels pass; same rect fn).
+ #[cfg(feature = "wpe")]
+ for (_, _, (cx, cy, cw, ch)) in self.html_chip_specs() {
+ quads.push((cx, cy, cw, ch, [0.14, 0.14, 0.20, 1.0]));
+ quads.push((cx, cy, cw, 1.0, [0.25, 0.35, 0.50, 0.40]));
+ quads.push((cx, cy + ch - 1.0, cw, 1.0, [0.25, 0.35, 0.50, 0.40]));
+ quads.push((cx, cy, 1.0, ch, [0.25, 0.35, 0.50, 0.40]));
+ quads.push((cx + cw - 1.0, cy, 1.0, ch, [0.25, 0.35, 0.50, 0.40]));
+ }
+
let body_w = (w_f32 - (detail_x + 15.0)).max(100.0);
let line_h = 12.0 * 1.4; // get_text_buffer_laid_out's placed-text metric
+ // The rendered HTML view: one image quad where the
+ // text body would go. WebKit owns scrolling inside
+ // the page, so the app's body scroll state is
+ // disarmed while this is up.
+ #[cfg(feature = "wpe")]
+ let html_img = if self.html_active() {
+ let s = self.scale_factor as f32;
+ self.webview.resize(
+ (body_w * s) as u32,
+ (body_h * s) as u32,
+ s,
+ );
+ self.webview_rect = (detail_x, body_y, body_w, body_h);
+ self.webview.image()
+ } else {
+ None
+ };
+ #[cfg(not(feature = "wpe"))]
+ let html_img: Option<(u32, u32, u32)> = None;
+
+ if let Some((img_id, ..)) = html_img {
+ self.body_content_h = 0.0;
+ self.body_scroll = 0.0;
+ // White ground: frames lag a resize by a beat,
+ // and mail HTML assumes a white canvas.
+ quads.push((detail_x, body_y, body_w, body_h, [1.0, 1.0, 1.0, 1.0]));
+ quads.pc.image(
+ img_id,
+ cce_ui::scene::layout::Rect {
+ x: detail_x,
+ y: body_y,
+ width: body_w,
+ height: body_h,
+ },
+ 1.0,
+ );
+ } else {
+
// Measure with the exact shaping the renderer will use, so the
// scroll clamp and the thumb track the real wrapped height.
let (buffer, _) = cce_ui::engine::get_text_buffer_laid_out(
@@ -4028,6 +4501,7 @@ impl Application for ClearEmailApp {
align_v: cce_ui::scene::paint::AlignV::Top,
},
);
+ }
}
}
}
@@ -4225,7 +4699,15 @@ impl Application for ClearEmailApp {
}
}
- // Detail pane: no hover routing — no widgets there any more.
+ // Detail pane: no widget hover routing — but the rendered HTML
+ // page tracks the pointer (hover states, drag selection). While
+ // a press is held the page keeps the pointer even outside the
+ // rect, so selections drag naturally.
+ #[cfg(feature = "wpe")]
+ if self.html_on_show() && (self.webview_mouse_down || self.in_webview(px, py)) {
+ let (wx, wy) = self.webview_px(px, py);
+ self.webview.mouse_move(wx, wy);
+ }
}
if changed {
@@ -4327,6 +4809,20 @@ impl Application for ClearEmailApp {
}
}
}
+ // HTML-view chips: same convention, right side of the band.
+ #[cfg(feature = "wpe")]
+ for (_, chip, (cx, cy, cw, ch)) in self.html_chip_specs() {
+ if px >= cx && px <= cx + cw && py >= cy && py <= cy + ch {
+ match chip {
+ HtmlChip::ToggleView => self.show_text = !self.show_text,
+ HtmlChip::ToggleImages => {
+ let lift = !self.webview.images_allowed();
+ self.webview.set_images_allowed(lift);
+ }
+ }
+ changed = true;
+ }
+ }
}
ElementState::Released => {
if std::mem::take(&mut self.body_sb_dragging) {
@@ -4571,10 +5067,37 @@ impl Application for ClearEmailApp {
}
}
- // The detail pane takes no events at all now: Reply/Delete/Mark
- // Read/Unread moved to the Message menu, and detail_body is a
- // read-only boxed-text render — focusing the TextBox only let you
- // invisibly edit the display copy.
+ // The detail pane takes no widget events — but the rendered HTML
+ // page does take raw pointer input (text selection, link
+ // clicks). Presses reach here only after every overlay (card
+ // menu, bar dropdowns) had its chance and returned; the release
+ // follows the press wherever the pointer went, so a drag out of
+ // the pane still ends cleanly.
+ #[cfg(feature = "wpe")]
+ if button == MouseButton::Left {
+ match state {
+ ElementState::Pressed => {
+ if !scrollbar_took_press
+ && !self.body_sb_dragging
+ && !self.split_dragging
+ && self.html_on_show()
+ && self.in_webview(px, py)
+ {
+ let (wx, wy) = self.webview_px(px, py);
+ self.webview.mouse_button_ui(button, true, wx, wy);
+ self.webview_mouse_down = true;
+ changed = true;
+ }
+ }
+ ElementState::Released => {
+ if std::mem::take(&mut self.webview_mouse_down) {
+ let (wx, wy) = self.webview_px(px, py);
+ self.webview.mouse_button_ui(button, false, wx, wy);
+ changed = true;
+ }
+ }
+ }
+ }
}
if changed {
@@ -4599,19 +5122,40 @@ impl Application for ClearEmailApp {
}
}
- // Detail-pane body scroll.
+ // Detail-pane body scroll — or, in the HTML view, the page's own
+ // scrolling: the wheel goes to the engine (winit-signed device px,
+ // the webview's convention) and WebKit moves the document.
if !self.compose_open && self.selected_email_id.is_some() {
if px > separator_x {
- let dy = match delta {
- MouseScrollDelta::LineDelta(_, y) => -y * 24.0,
- MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
- };
- let (_, body_h) = self.detail_body_geom();
- let max = (self.body_content_h - body_h).max(0.0);
- let old = self.body_scroll;
- self.body_scroll = (self.body_scroll + dy).clamp(0.0, max);
- if (self.body_scroll - old).abs() > 0.01 {
- changed = true;
+ #[cfg(feature = "wpe")]
+ let html_wheel = self.html_on_show() && self.in_webview(px, py);
+ #[cfg(not(feature = "wpe"))]
+ let html_wheel = false;
+ if html_wheel {
+ #[cfg(feature = "wpe")]
+ {
+ let s = self.scale_factor;
+ let (dx, dy) = match delta {
+ MouseScrollDelta::LineDelta(x, y) => {
+ ((*x as f64) * 24.0 * s, (*y as f64) * 24.0 * s)
+ }
+ MouseScrollDelta::PixelDelta(p) => (p.x * s, p.y * s),
+ };
+ let (wx, wy) = self.webview_px(px, py);
+ self.webview.wheel(dx, dy, wx, wy);
+ }
+ } else {
+ let dy = match delta {
+ MouseScrollDelta::LineDelta(_, y) => -y * 24.0,
+ MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
+ };
+ let (_, body_h) = self.detail_body_geom();
+ let max = (self.body_content_h - body_h).max(0.0);
+ let old = self.body_scroll;
+ self.body_scroll = (self.body_scroll + dy).clamp(0.0, max);
+ if (self.body_scroll - old).abs() > 0.01 {
+ changed = true;
+ }
}
}
}
@@ -4727,6 +5271,11 @@ impl Application for ClearEmailApp {
}
}
+ // Read before ctx: html_on_show(&self) cannot run while ui_context
+ // is mutably borrowed, and nothing below changes what it reads.
+ #[cfg(feature = "wpe")]
+ let html_on_show = self.html_on_show();
+
let ctx = &mut self.ui_context;
if self.compose_open {
@@ -4786,6 +5335,34 @@ impl Application for ClearEmailApp {
}
}
+ // HTML view: page keys and the copy chord go to the engine
+ // (hover-scoped like the text body's keys below). Up/Down stay
+ // with the list selection either way.
+ #[cfg(feature = "wpe")]
+ if !handled
+ && self.detail_hovered
+ && html_on_show
+ && !self.search_box.editing
+ && event.state == ElementState::Pressed
+ {
+ use cce_ui::widget::NamedKey;
+ let forward = match &event.logical_key {
+ Key::Named(
+ NamedKey::PageDown | NamedKey::PageUp | NamedKey::Home | NamedKey::End,
+ ) => true,
+ Key::Character(c) if event.ctrl && c.eq_ignore_ascii_case("c") => {
+ self.webview.copy_selection();
+ handled = true;
+ false
+ }
+ _ => false,
+ };
+ if forward {
+ self.webview.key_ui(event);
+ handled = true;
+ }
+ }
+
// Detail-pane body scroll, hover-scoped like ScrollRegion's keyboard path.
if !handled
&& self.detail_hovered
@@ -5258,6 +5835,45 @@ mod tests {
assert!(find_text_part(&bs).is_none());
}
+ #[cfg(feature = "wpe")]
+ #[test]
+ fn html_part_walk_prefers_html_over_plain() {
+ // The same alternative the sync reads plain out of: the HTML view
+ // must land on the other branch.
+ let bs = multipart(
+ "MIXED",
+ vec![
+ multipart(
+ "ALTERNATIVE",
+ vec![
+ text_part("PLAIN", ContentEncoding::QuotedPrintable, Some(("CHARSET", "UTF-8"))),
+ text_part("HTML", ContentEncoding::Base64, Some(("CHARSET", "ISO-8859-1"))),
+ ],
+ ),
+ basic_part("APPLICATION", "PDF"),
+ ],
+ );
+ let spec = find_html_part(&bs).expect("finds the html part");
+ assert_eq!(section_str(&spec.path), "1.2");
+ assert_eq!(spec.subtype, "html");
+ assert_eq!(spec.encoding, "base64");
+ assert_eq!(spec.charset.as_deref(), Some("ISO-8859-1"));
+ }
+
+ #[cfg(feature = "wpe")]
+ #[test]
+ fn html_part_walk_toplevel_and_absent() {
+ // Non-multipart text/html is section 1 (RFC 3501).
+ let spec = find_html_part(&text_part("HTML", ContentEncoding::SevenBit, None)).unwrap();
+ assert_eq!(spec.path, vec![1]);
+ // Plain-only mail has no HTML view: the pane stays on text.
+ let bs = multipart(
+ "MIXED",
+ vec![text_part("PLAIN", ContentEncoding::SevenBit, None)],
+ );
+ assert!(find_html_part(&bs).is_none());
+ }
+
#[test]
fn synthesized_part_decodes_via_mail_parser() {
let spec = TextPartSpec {
diff --git a/src/wpe/glib_source.rs b/src/wpe/glib_source.rs
new file mode 100644
index 0000000..76cd43b
--- /dev/null
+++ b/src/wpe/glib_source.rs
@@ -0,0 +1,139 @@
+//! Waking on GLib activity instead of polling for it.
+//!
+//! WPE runs on a GLib `GMainContext`; cce-ui runs a calloop loop. The first
+//! cut of [`super::WebKitHost::pump`] simply drained the context on a timer,
+//! which works but burns wakeups when nothing is happening and adds latency
+//! when something is.
+//!
+//! The bridge here is deliberately narrow: **calloop decides *when to look*,
+//! GLib still does its own iteration.** We never reimplement GLib's
+//! prepare/check/dispatch protocol — `g_main_context_iteration` does that,
+//! correctly, and we only use `g_main_context_query` to learn what to wait on.
+//!
+//! GLib's fd set changes as WebKit opens sockets, and calloop wants stable
+//! registrations, so the changing set lives in an **inner epoll fd** that is
+//! itself the one stable thing calloop watches. Each pump re-syncs that set.
+//! GLib also asks for a timeout, which a calloop timer carries.
+
+use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
+
+use rustix::event::epoll;
+
+use super::ffi::*;
+
+/// The GLib fd set, mirrored into one epoll fd that calloop can watch.
+pub(super) struct GlibPoll {
+ epfd: OwnedFd,
+ /// What is currently registered, so a re-sync can diff rather than
+ /// teardown-and-rebuild every pump.
+ registered: Vec<(i32, epoll::EventFlags)>,
+ fds: Vec<GPollFD>,
+ /// GLib's requested timeout in ms; `None` means "no timer needed".
+ pub(super) timeout: Option<u32>,
+}
+
+fn flags_of(events: u16) -> epoll::EventFlags {
+ let mut f = epoll::EventFlags::empty();
+ // G_IO_IN / OUT / ERR / HUP, which are the poll(2) values.
+ if events & 0x001 != 0 {
+ f |= epoll::EventFlags::IN;
+ }
+ if events & 0x004 != 0 {
+ f |= epoll::EventFlags::OUT;
+ }
+ if events & 0x008 != 0 {
+ f |= epoll::EventFlags::ERR;
+ }
+ if events & 0x010 != 0 {
+ f |= epoll::EventFlags::HUP;
+ }
+ f
+}
+
+impl GlibPoll {
+ pub(super) fn new() -> std::io::Result<Self> {
+ let epfd = epoll::create(epoll::CreateFlags::CLOEXEC)?;
+ let mut this = Self {
+ epfd,
+ registered: Vec::new(),
+ fds: Vec::new(),
+ timeout: None,
+ };
+ this.sync();
+ Ok(this)
+ }
+
+ pub(super) fn fd(&self) -> BorrowedFd<'_> {
+ self.epfd.as_fd()
+ }
+
+ /// Ask GLib what it wants polled, and make the epoll set match.
+ ///
+ /// Called after every dispatch, because WebKit adds and drops fds as it
+ /// opens connections — a set captured once goes stale within a page load.
+ pub(super) fn sync(&mut self) {
+ unsafe {
+ let ctx = g_main_context_default();
+ // `query` is only meaningful between prepare and check; we are not
+ // running that protocol ourselves, but prepare also updates the
+ // context's own idea of the timeout, so call it for that.
+ let mut max_priority: i32 = 0;
+ g_main_context_prepare(ctx, &mut max_priority);
+
+ let mut timeout: i32 = -1;
+ // Two-pass: ask for the count, then fill.
+ let n = g_main_context_query(ctx, max_priority, &mut timeout, std::ptr::null_mut(), 0);
+ self.fds.clear();
+ self.fds.resize(n.max(0) as usize, std::mem::zeroed());
+ let n = if self.fds.is_empty() {
+ 0
+ } else {
+ g_main_context_query(
+ ctx,
+ max_priority,
+ &mut timeout,
+ self.fds.as_mut_ptr(),
+ self.fds.len() as i32,
+ )
+ };
+ self.fds.truncate(n.max(0) as usize);
+ self.timeout = (timeout >= 0).then_some(timeout as u32);
+ }
+
+ let want: Vec<(i32, epoll::EventFlags)> = self
+ .fds
+ .iter()
+ .map(|p| (p.fd, flags_of(p.events)))
+ .collect();
+
+ // Diff against what is registered. Same-fd-different-flags is a
+ // modify, not a delete plus add, so a busy socket is not churned.
+ for (fd, flags) in &want {
+ let borrowed = unsafe { BorrowedFd::borrow_raw(*fd) };
+ let data = epoll::EventData::new_u64(*fd as u64);
+ match self.registered.iter().find(|(f, _)| f == fd) {
+ Some((_, old)) if old == flags => {}
+ Some(_) => {
+ let _ = epoll::modify(&self.epfd, borrowed, data, *flags);
+ }
+ None => {
+ let _ = epoll::add(&self.epfd, borrowed, data, *flags);
+ }
+ }
+ }
+ for (fd, _) in &self.registered {
+ if !want.iter().any(|(f, _)| f == fd) {
+ let _ = epoll::delete(&self.epfd, unsafe { BorrowedFd::borrow_raw(*fd) });
+ }
+ }
+ self.registered = want;
+ }
+
+ /// Drain the inner epoll so it stops reporting readable. calloop is
+ /// level-triggered on this fd; without this the loop would spin on a
+ /// socket GLib has not consumed yet.
+ pub(super) fn drain(&self) {
+ let mut events = epoll::EventVec::with_capacity(16);
+ let _ = epoll::wait(&self.epfd, &mut events, 0);
+ }
+}
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
new file mode 100644
index 0000000..56a0bc9
--- /dev/null
+++ b/src/wpe/host.rs
@@ -0,0 +1,680 @@
+//! `MailWebView` — one sandboxed WPE WebKit view for rendering HTML mail.
+//!
+//! The mail-shaped sibling of cce-browser's `WebKitHost`: same boot (the
+//! GObject subclasses in `subclass.rs`), same frame pipeline (SHM readback →
+//! `cce_ui::vk::upload_rgba` → one quad in the detail pane), same calloop
+//! bridge (`glib_source.rs`) — but a single view instead of tabs, and locked
+//! down for hostile content:
+//!
+//! * **JavaScript is off.** Mail is not an application platform.
+//! * **The network session is ephemeral** — no cookies or cache ever touch
+//! disk.
+//! * **All remote loads are blocked by default** by a compiled WebKit content
+//! filter (`data:` stays allowed for inline images). Tracking pixels never
+//! fire. [`MailWebView::set_images_allowed`] lifts the filter for the
+//! current message only — an explicit per-message choice, reset on the
+//! next [`MailWebView::load_html`].
+//! * **Navigation never happens in-pane.** A link click is intercepted by
+//! `decide-policy` and handed back through [`MailWebView::take_link_click`]
+//! for the app to open externally; form submissions are dropped.
+//!
+//! The web process is lazy: constructing the host boots only the WPE display
+//! and toplevel (cheap, no child processes). WebKit's processes spawn on the
+//! first [`MailWebView::load_html`], so a text-only session pays nothing.
+
+use std::cell::{Cell, RefCell};
+use std::ffi::{c_char, c_void, CString};
+use std::rc::Rc;
+
+use cce_ui::widget::{KeyEvent, MouseButton};
+
+use super::ffi::*;
+use super::glib_source::GlibPoll;
+use super::input;
+use super::subclass::{types, FRAME_SINK};
+
+unsafe fn cstr(s: &str) -> CString {
+ CString::new(s).expect("no interior nul")
+}
+
+unsafe fn from_cstr(p: *const c_char) -> Option<String> {
+ (!p.is_null())
+ .then(|| std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned())
+ .filter(|s| !s.is_empty())
+}
+
+/// 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 the current one is released anyway.
+#[derive(Default)]
+struct Pending {
+ frame: Option<(Vec<u8>, u32, u32)>,
+}
+
+/// The WebKit content filter source: block every URL except `data:`, so a
+/// message renders from its own bytes alone. Compiled once (WebKit caches
+/// the compiled form in the store directory) and attached to the UCM
+/// whenever remote content is disallowed.
+const BLOCK_REMOTE_FILTER: &str = r#"[
+ {"trigger": {"url-filter": ".*"}, "action": {"type": "block"}},
+ {"trigger": {"url-filter": "^data:"}, "action": {"type": "ignore-previous-rules"}}
+]"#;
+
+pub struct MailWebView {
+ display: *mut WPEDisplay,
+ toplevel: *mut WPEToplevel,
+ /// Created on the first `load_html`, kept for the life of the host.
+ webview: Option<(*mut WebKitWebView, *mut WPEView)>,
+ session: *mut WebKitNetworkSession,
+ ucm: *mut WebKitUserContentManager,
+ /// The compiled block-everything filter; null if compilation failed (in
+ /// which case remote loads are stopped by `auto-load-images` alone).
+ filter: *mut WebKitUserContentFilter,
+ size_px: (u32, u32),
+ scale: f32,
+ pending: Rc<RefCell<Pending>>,
+ /// GLib's pollfd set, mirrored into one epoll fd for calloop.
+ poll: Option<GlibPoll>,
+ /// Link URIs the page tried to navigate to, stashed by `decide-policy`.
+ links: Rc<RefCell<Vec<String>>>,
+ /// The message currently loaded, kept so lifting the image block can
+ /// re-render the same content.
+ html: Option<CString>,
+ images_allowed: bool,
+ /// Last uploaded frame in the image registry: (id, w px, h px).
+ image: Option<(u32, u32, u32)>,
+ /// Retained so the headless example can assert on rendered output.
+ last_frame: Option<(Vec<u8>, u32, u32)>,
+}
+
+impl Drop for MailWebView {
+ fn drop(&mut self) {
+ unsafe {
+ if let Some((wv, _)) = self.webview.take() {
+ g_object_unref(wv as *mut _);
+ }
+ }
+ if let Some((id, ..)) = self.image.take() {
+ cce_ui::vk::free_image(id);
+ }
+ }
+}
+
+impl MailWebView {
+ /// Boot WPE (display + toplevel + content filter). One host per process:
+ /// the frame sink and the GType registrations are process-wide.
+ pub fn new(size_px: (u32, u32)) -> Self {
+ unsafe {
+ let t = types();
+ let display = g_object_new(t.display, std::ptr::null::<c_char>()) as *mut WPEDisplay;
+ let mut err: *mut GError = std::ptr::null_mut();
+ assert!(
+ wpe_display_connect(display, &mut err) != 0,
+ "wpe_display_connect failed"
+ );
+
+ // Ephemeral: mail content must leave no cookie jar and no cache.
+ let session = webkit_network_session_new_ephemeral();
+
+ let pending = Rc::new(RefCell::new(Pending::default()));
+ let sink = pending.clone();
+ FRAME_SINK = Some(Box::new(move |buffer: *mut WPEBuffer| {
+ if let Some(f) = read_shm(buffer) {
+ // Replace, never accumulate: the newest frame wins.
+ sink.borrow_mut().frame = Some(f);
+ }
+ }));
+
+ let toplevel = wpe_display_create_toplevel(display, 1);
+ wpe_toplevel_resized(toplevel, size_px.0 as i32, size_px.1 as i32);
+
+ let filter = compile_block_filter();
+
+ Self {
+ display,
+ toplevel,
+ webview: None,
+ session,
+ ucm: webkit_user_content_manager_new(),
+ filter,
+ size_px,
+ scale: 1.0,
+ pending,
+ poll: GlibPoll::new()
+ .map_err(|e| eprintln!("cce-mail: no GLib epoll bridge ({e}); pump will poll"))
+ .ok(),
+ links: Rc::new(RefCell::new(Vec::new())),
+ html: None,
+ images_allowed: false,
+ image: None,
+ last_frame: None,
+ }
+ }
+ }
+
+ /// The webview, created on first use — this is what spawns WebKit's
+ /// child processes, so it only happens once HTML actually arrives.
+ fn ensure_view(&mut self) -> (*mut WebKitWebView, *mut WPEView) {
+ if let Some(pair) = self.webview {
+ return pair;
+ }
+ unsafe {
+ 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;
+
+ // The lockdown. JavaScript stays off for the life of the view;
+ // images follow `images_allowed`.
+ let settings = webkit_web_view_get_settings(wv);
+ webkit_settings_set_enable_javascript(settings, 0);
+ webkit_settings_set_auto_load_images(settings, self.images_allowed as gboolean);
+ self.apply_filter_policy();
+
+ // Link clicks leave through the app, never navigate in-pane.
+ let sig = cstr("decide-policy");
+ g_signal_connect_data(
+ wv as *mut _,
+ sig.as_ptr(),
+ Some(std::mem::transmute::<_, unsafe extern "C" fn()>(
+ on_decide_policy
+ as unsafe extern "C" fn(
+ *mut WebKitWebView,
+ *mut WebKitPolicyDecision,
+ WebKitPolicyDecisionType::Type,
+ gpointer,
+ ) -> gboolean,
+ )),
+ Rc::into_raw(self.links.clone()) as gpointer,
+ Some(drop_links_ref),
+ 0,
+ );
+
+ let view = webkit_web_view_get_wpe_view(wv);
+ wpe_view_set_toplevel(view, self.toplevel);
+ let (lw, lh) = self.logical_size();
+ wpe_view_resized(view, lw, lh);
+ wpe_view_set_visible(view, 1);
+ wpe_view_map(view);
+ // Without focus the page has no focused frame and forwarded
+ // keyboard input (PageDown, Ctrl+C) is silently dropped.
+ wpe_view_focus_in(view);
+ self.webview = Some((wv, view));
+ (wv, view)
+ }
+ }
+
+ /// Attach or detach the block-everything filter to match
+ /// `images_allowed`. WebKit applies UCM changes to live pages.
+ fn apply_filter_policy(&self) {
+ if self.filter.is_null() {
+ return;
+ }
+ unsafe {
+ if self.images_allowed {
+ webkit_user_content_manager_remove_all_filters(self.ucm);
+ } else {
+ webkit_user_content_manager_add_filter(self.ucm, self.filter);
+ }
+ }
+ }
+
+ /// Show a message. Always re-arms the remote-content block: allowing
+ /// images is a per-message decision, never a sticky one.
+ pub fn load_html(&mut self, html: &str) {
+ self.images_allowed = false;
+ // NUL bytes would truncate the CString; they carry no meaning in
+ // HTML, so strip rather than fail.
+ let owned;
+ let clean = if html.contains('\0') {
+ owned = html.replace('\0', "");
+ owned.as_str()
+ } else {
+ html
+ };
+ self.html = Some(unsafe { cstr(clean) });
+ self.reload_current();
+ }
+
+ /// Drop the shown message (selection cleared / folder switched). The
+ /// view and its processes stay for the next message.
+ pub fn clear(&mut self) {
+ self.html = None;
+ self.links.borrow_mut().clear();
+ self.pending.borrow_mut().frame = None;
+ if let Some((id, ..)) = self.image.take() {
+ cce_ui::vk::free_image(id);
+ }
+ if let Some((wv, _)) = self.webview {
+ unsafe {
+ let blank = cstr("about:blank");
+ webkit_web_view_load_uri(wv, blank.as_ptr());
+ }
+ }
+ }
+
+ /// Lift (or restore) the remote-content block for the current message
+ /// and re-render it.
+ pub fn set_images_allowed(&mut self, allowed: bool) {
+ if allowed == self.images_allowed {
+ return;
+ }
+ self.images_allowed = allowed;
+ self.apply_filter_policy();
+ self.reload_current();
+ }
+
+ pub fn images_allowed(&self) -> bool {
+ self.images_allowed
+ }
+
+ fn reload_current(&mut self) {
+ let Some(html) = self.html.clone() else { return };
+ let (wv, _) = self.ensure_view();
+ unsafe {
+ let settings = webkit_web_view_get_settings(wv);
+ webkit_settings_set_auto_load_images(settings, self.images_allowed as gboolean);
+ webkit_web_view_load_html(wv, html.as_ptr(), std::ptr::null());
+ }
+ // The old message's frame must not linger under the new one — the
+ // app falls back to the text body until the first frame lands.
+ self.pending.borrow_mut().frame = None;
+ if let Some((id, ..)) = self.image.take() {
+ cce_ui::vk::free_image(id);
+ }
+ }
+
+ /// A link the user clicked in the message, if any (FIFO).
+ pub fn take_link_click(&self) -> Option<String> {
+ let mut links = self.links.borrow_mut();
+ (!links.is_empty()).then(|| links.remove(0))
+ }
+
+ /// The epoll fd carrying GLib's pollfd set, duplicated for calloop.
+ /// `None` if the bridge could not be created — fall back to the timer.
+ pub fn poll_fd_owned(&self) -> Option<std::os::fd::OwnedFd> {
+ let fd = self.poll.as_ref()?.fd();
+ rustix::io::dup(fd).ok()
+ }
+
+ /// How long calloop may sleep before pumping anyway, per GLib.
+ pub fn poll_timeout(&self) -> Option<std::time::Duration> {
+ self.poll
+ .as_ref()
+ .and_then(|p| p.timeout)
+ .map(|ms| std::time::Duration::from_millis(ms as u64))
+ }
+
+ /// Drain GLib's pending work, then upload any frame it produced.
+ /// Returns true when a new frame landed (the pane needs a repaint).
+ pub fn pump(&mut self) -> bool {
+ // Clear the inner epoll first: calloop is level-triggered on that fd,
+ // so leaving it readable across a pump that does not consume the
+ // underlying socket would spin the loop.
+ if let Some(p) = &self.poll {
+ p.drain();
+ }
+ unsafe {
+ while g_main_context_iteration(std::ptr::null_mut(), 0) != 0 {}
+ }
+ // WebKit opens and drops sockets as it loads, so the set that matters
+ // is the one *after* dispatch, not before.
+ if let Some(p) = &mut self.poll {
+ p.sync();
+ }
+ let Some((px, w, h)) = self.pending.borrow_mut().frame.take() else {
+ return false;
+ };
+ self.last_frame = Some((px.clone(), w, h));
+ let id = cce_ui::vk::upload_rgba(px, w, h);
+ if let Some((old, ..)) = self.image.replace((id, w, h)) {
+ cce_ui::vk::free_image(old);
+ }
+ true
+ }
+
+ /// The current frame in the image registry: (id, w px, h px).
+ pub fn image(&self) -> Option<(u32, u32, u32)> {
+ self.image
+ }
+
+ /// A pixel of the last frame, for tests asserting on rendered output
+ /// (examples/wpe_mail.rs; dead in the app build).
+ #[allow(dead_code)]
+ pub fn sample_pixel(&self, x: u32, y: u32) -> Option<(u8, u8, u8)> {
+ let (px, w, h) = self.last_frame.as_ref()?;
+ if x >= *w || y >= *h {
+ return None;
+ }
+ let i = ((y * w + x) * 4) as usize;
+ Some((px[i], px[i + 1], px[i + 2]))
+ }
+
+ /// Put the page's current selection on the system clipboard (the
+ /// clipboard subclass routes it through cce-ui's wl-copy helper).
+ pub fn copy_selection(&self) {
+ if let Some((wv, _)) = self.webview {
+ unsafe {
+ let c = cstr("Copy");
+ webkit_web_view_execute_editing_command(wv, c.as_ptr());
+ }
+ }
+ }
+
+ // ---- input ----
+ //
+ // Coordinates are device pixels relative to the view origin (the
+ // browser's convention); the host converts to WPE's logical space.
+
+ pub fn mouse_move(&mut self, x_px: f32, y_px: f32) {
+ let Some((_, view)) = self.webview else { return };
+ unsafe {
+ let (x, y) = self.to_logical(x_px, y_px);
+ let e = wpe_event_pointer_move_new(
+ WPEEventType::WPE_EVENT_POINTER_MOVE,
+ view,
+ WPEInputSource::WPE_INPUT_SOURCE_MOUSE,
+ input::now_ms(),
+ 0,
+ x,
+ y,
+ 0.0,
+ 0.0,
+ );
+ self.send(view, e);
+ }
+ }
+
+ pub fn mouse_button_ui(&mut self, button: MouseButton, pressed: bool, x_px: f32, y_px: f32) {
+ let Some(n) = input::button_number(button) else {
+ return;
+ };
+ let Some((_, view)) = self.webview else { return };
+ unsafe {
+ let time = input::now_ms();
+ let (x, y) = self.to_logical(x_px, y_px);
+ // WPE tracks double/triple clicks for us; a frozen clock here
+ // would make every click read as a repeat.
+ let press_count = if pressed {
+ wpe_view_compute_press_count(view, x, y, n, time)
+ } else {
+ 0
+ };
+ let e = wpe_event_pointer_button_new(
+ if pressed {
+ WPEEventType::WPE_EVENT_POINTER_DOWN
+ } else {
+ WPEEventType::WPE_EVENT_POINTER_UP
+ },
+ view,
+ WPEInputSource::WPE_INPUT_SOURCE_MOUSE,
+ time,
+ 0,
+ n,
+ x,
+ y,
+ press_count,
+ );
+ self.send(view, e);
+ }
+ }
+
+ /// Wheel deltas in device pixels, winit-signed (positive = up), passed
+ /// through unchanged — WPE inverts on the way to the DOM itself.
+ pub fn wheel(&mut self, dx_px: f64, dy_px: f64, x_px: f32, y_px: f32) {
+ let Some((_, view)) = self.webview else { return };
+ unsafe {
+ let (x, y) = self.to_logical(x_px, y_px);
+ let e = wpe_event_scroll_new(
+ view,
+ WPEInputSource::WPE_INPUT_SOURCE_MOUSE,
+ input::now_ms(),
+ 0,
+ dx_px / self.scale as f64,
+ dy_px / self.scale as f64,
+ 1, // precise deltas: these are pixels, not notches
+ 0, // not a scroll-stop event
+ x,
+ y,
+ );
+ self.send(view, e);
+ }
+ }
+
+ /// Forward a cce-ui key event (page scrolling, copy chords).
+ pub fn key_ui(&mut self, event: &KeyEvent) {
+ let Some(keyval) = input::keyval(&event.logical_key) else {
+ return;
+ };
+ let Some((_, view)) = self.webview else { return };
+ let pressed = input::is_pressed(event);
+ unsafe {
+ let e = wpe_event_keyboard_new(
+ if pressed {
+ WPEEventType::WPE_EVENT_KEYBOARD_KEY_DOWN
+ } else {
+ WPEEventType::WPE_EVENT_KEYBOARD_KEY_UP
+ },
+ view,
+ WPEInputSource::WPE_INPUT_SOURCE_KEYBOARD,
+ input::now_ms(),
+ input::modifiers(event.ctrl, event.shift, event.alt),
+ 0, // hardware keycode: unknown to us, WebKit works off keyval
+ keyval,
+ );
+ self.send(view, e);
+ }
+ }
+
+ unsafe fn send(&self, view: *mut WPEView, event: *mut WPEEvent) {
+ if event.is_null() {
+ return;
+ }
+ wpe_view_event(view, event);
+ wpe_event_unref(event);
+ }
+
+ /// Resize, in **physical** pixels plus the scale. WPE wants a logical
+ /// size and produces a buffer of `size * scale` — handing it physical
+ /// pixels at scale 1 would lay out double-width CSS on a 2x display.
+ pub fn resize(&mut self, width_px: u32, height_px: u32, scale: f32) {
+ let size = (width_px.max(1), height_px.max(1));
+ let scale = scale.max(0.01);
+ if size == self.size_px && (scale - self.scale).abs() < 1.0e-3 {
+ return;
+ }
+ self.size_px = size;
+ self.scale = scale;
+ let (lw, lh) = self.logical_size();
+ unsafe {
+ wpe_toplevel_scale_changed(self.toplevel, self.scale as f64);
+ wpe_toplevel_resized(self.toplevel, lw, lh);
+ if let Some((_, view)) = self.webview {
+ wpe_view_resized(view, lw, lh);
+ }
+ }
+ }
+
+ /// The view size WPE works in: physical divided back out by the scale.
+ fn logical_size(&self) -> (i32, i32) {
+ (
+ ((self.size_px.0 as f32 / self.scale).round() as i32).max(1),
+ ((self.size_px.1 as f32 / self.scale).round() as i32).max(1),
+ )
+ }
+
+ fn to_logical(&self, x_px: f32, y_px: f32) -> (f64, f64) {
+ ((x_px / self.scale) as f64, (y_px / self.scale) as f64)
+ }
+}
+
+/// Compile (or load from WebKit's cache) the block-remote content filter.
+///
+/// The store API is async; the surrounding code is a constructor with a GLib
+/// context and nothing else running on it yet, so this blocks on bounded
+/// context iterations until the callback lands. Null on failure — the caller
+/// degrades to `auto-load-images` alone.
+unsafe fn compile_block_filter() -> *mut WebKitUserContentFilter {
+ struct Slot {
+ done: Cell<bool>,
+ filter: Cell<*mut WebKitUserContentFilter>,
+ }
+ unsafe extern "C" fn on_saved(source: *mut GObject, res: *mut GAsyncResult, data: gpointer) {
+ let slot = &*(data as *const Slot);
+ let mut err: *mut GError = std::ptr::null_mut();
+ let f = webkit_user_content_filter_store_save_finish(
+ source as *mut WebKitUserContentFilterStore,
+ res,
+ &mut err,
+ );
+ if f.is_null() {
+ let msg = (!err.is_null())
+ .then(|| from_cstr((*err).message))
+ .flatten()
+ .unwrap_or_else(|| "unknown error".into());
+ eprintln!("cce-mail: content filter failed to compile ({msg})");
+ if !err.is_null() {
+ g_error_free(err);
+ }
+ }
+ slot.filter.set(f);
+ slot.done.set(true);
+ }
+
+ let dir = std::env::var_os("XDG_STATE_HOME")
+ .map(std::path::PathBuf::from)
+ .filter(|p| p.is_absolute())
+ .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local/state")))
+ .map(|p| p.join("cce/mail/content-filters"));
+ let Some(dir) = dir else {
+ eprintln!("cce-mail: no HOME; remote-content filter disabled");
+ return std::ptr::null_mut();
+ };
+ let _ = std::fs::create_dir_all(&dir);
+
+ let cdir = cstr(&dir.to_string_lossy());
+ let store = webkit_user_content_filter_store_new(cdir.as_ptr());
+ let id = cstr("block-remote");
+ let bytes = g_bytes_new(
+ BLOCK_REMOTE_FILTER.as_ptr() as *const c_void,
+ BLOCK_REMOTE_FILTER.len() as u64,
+ );
+ let slot = Box::new(Slot {
+ done: Cell::new(false),
+ filter: Cell::new(std::ptr::null_mut()),
+ });
+ webkit_user_content_filter_store_save(
+ store,
+ id.as_ptr(),
+ bytes,
+ std::ptr::null_mut(),
+ Some(on_saved),
+ slot.as_ref() as *const Slot as gpointer,
+ );
+ // Blocking iterations; the cap turns a wedged store into a filterless
+ // start instead of a hang.
+ for _ in 0..10_000 {
+ if slot.done.get() {
+ break;
+ }
+ g_main_context_iteration(std::ptr::null_mut(), 1);
+ }
+ g_bytes_unref(bytes);
+ g_object_unref(store as *mut _);
+ if !slot.done.get() {
+ eprintln!("cce-mail: content filter compile timed out; remote loads gated by image setting only");
+ // The callback may still fire later against the leaked slot.
+ Box::leak(slot);
+ return std::ptr::null_mut();
+ }
+ slot.filter.get()
+}
+
+unsafe extern "C" fn drop_links_ref(data: gpointer, _c: *mut GClosure) {
+ drop(Rc::from_raw(data as *const RefCell<Vec<String>>));
+}
+
+/// Every navigation decision. The initial `load_html` arrives as type OTHER
+/// and passes; a clicked link is stashed for external opening; everything
+/// else (forms, window.open targets) is refused outright.
+unsafe extern "C" fn on_decide_policy(
+ _wv: *mut WebKitWebView,
+ decision: *mut WebKitPolicyDecision,
+ kind: WebKitPolicyDecisionType::Type,
+ data: gpointer,
+) -> gboolean {
+ let links = &*(data as *const RefCell<Vec<String>>);
+ match kind {
+ WebKitPolicyDecisionType::WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION
+ | WebKitPolicyDecisionType::WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION => {
+ let nav = decision as *mut WebKitNavigationPolicyDecision;
+ let action = webkit_navigation_policy_decision_get_navigation_action(nav);
+ let ty = webkit_navigation_action_get_navigation_type(action);
+ let is_click = ty == WebKitNavigationType::WEBKIT_NAVIGATION_TYPE_LINK_CLICKED;
+ let in_new_window =
+ kind == WebKitPolicyDecisionType::WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION;
+ if is_click || in_new_window {
+ let req = webkit_navigation_action_get_request(action);
+ if let Some(uri) = from_cstr(webkit_uri_request_get_uri(req)) {
+ links.borrow_mut().push(uri);
+ }
+ webkit_policy_decision_ignore(decision);
+ } else if ty == WebKitNavigationType::WEBKIT_NAVIGATION_TYPE_OTHER {
+ // The app's own load_html / about:blank clears.
+ webkit_policy_decision_use(decision);
+ } else {
+ // Form submits, reloads, back/forward: nothing a mail pane
+ // should ever do.
+ webkit_policy_decision_ignore(decision);
+ }
+ }
+ _ => {
+ webkit_policy_decision_use(decision);
+ }
+ }
+ 1
+}
+
+/// Copy an SHM buffer's pixels out as RGBA for `upload_rgba`.
+///
+/// `WPE_PIXEL_FORMAT_ARGB8888` is B,G,R,A in memory on little-endian, and the
+/// stride is not assumed to equal `width * 4`.
+unsafe fn read_shm(buffer: *mut WPEBuffer) -> Option<(Vec<u8>, u32, u32)> {
+ if g_type_check_instance_is_a(buffer as *mut GTypeInstance, wpe_buffer_shm_get_type()) == 0 {
+ return None;
+ }
+ let shm = buffer as *mut WPEBufferSHM;
+ let (w, h) = (
+ wpe_buffer_get_width(buffer) as u32,
+ wpe_buffer_get_height(buffer) as u32,
+ );
+ let mut len: u64 = 0;
+ let src = g_bytes_get_data(wpe_buffer_shm_get_data(shm), &mut len as *mut u64) as *const u8;
+ if src.is_null() || w == 0 || h == 0 {
+ return None;
+ }
+ let stride = wpe_buffer_shm_get_stride(shm) as usize;
+ let mut out = vec![0u8; (w * h * 4) as usize];
+ for y in 0..h as usize {
+ for x in 0..w as usize {
+ let s = src.add(y * stride + x * 4);
+ let d = (y * w as usize + x) * 4;
+ out[d] = *s.add(2);
+ out[d + 1] = *s.add(1);
+ out[d + 2] = *s;
+ out[d + 3] = *s.add(3);
+ }
+ }
+ Some((out, w, h))
+}
diff --git a/src/wpe/input.rs b/src/wpe/input.rs
new file mode 100644
index 0000000..506652e
--- /dev/null
+++ b/src/wpe/input.rs
@@ -0,0 +1,118 @@
+//! Translating cce-ui input into `WPEEvent`s.
+//!
+//! Unlike the Servo backend — where `main.rs` carried `dom_key`/`dom_button`
+//! helpers and the host took engine types — the mapping lives *here* and
+//! [`super::WebKitHost`] takes cce-ui's own `MouseButton` / `KeyEvent`. That
+//! keeps engine vocabulary out of the chrome, so switching backends deletes
+//! those helpers from `main.rs` rather than rewriting them.
+//!
+//! **Keyboard is the fiddly part.** `wpe_event_keyboard_new` wants an X11
+//! *keysym* (`keyval`), not a character. Latin-1 codepoints are their own
+//! keysym; anything above maps to `codepoint + 0x0100_0000`; named keys have
+//! fixed `XK_*` values. Getting this wrong is silent — the page just receives
+//! nothing useful.
+
+use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, NamedKey};
+
+use super::ffi::*;
+
+/// X11 keysyms for the named keys cce-ui reports (`/usr/include/X11/keysymdef.h`).
+mod keysym {
+ pub const BACKSPACE: u32 = 0xff08;
+ pub const TAB: u32 = 0xff09;
+ pub const RETURN: u32 = 0xff0d;
+ pub const ESCAPE: u32 = 0xff1b;
+ pub const SPACE: u32 = 0x0020;
+ pub const HOME: u32 = 0xff50;
+ pub const LEFT: u32 = 0xff51;
+ pub const UP: u32 = 0xff52;
+ pub const RIGHT: u32 = 0xff53;
+ pub const DOWN: u32 = 0xff54;
+ pub const PAGE_UP: u32 = 0xff55;
+ pub const PAGE_DOWN: u32 = 0xff56;
+ pub const END: u32 = 0xff57;
+ pub const DELETE: u32 = 0xffff;
+ pub const F5: u32 = 0xffc2;
+ pub const SHIFT_L: u32 = 0xffe1;
+ pub const CONTROL_L: u32 = 0xffe3;
+ pub const ALT_L: u32 = 0xffe9;
+ pub const SUPER_L: u32 = 0xffeb;
+}
+
+/// A Unicode scalar as an X11 keysym: Latin-1 is identity, the rest is the
+/// codepoint in the 0x01000000 plane.
+fn unicode_keysym(c: char) -> u32 {
+ match c as u32 {
+ cp @ 0x20..=0xff => cp,
+ cp => cp + 0x0100_0000,
+ }
+}
+
+/// cce-ui key -> X11 keysym. `None` for keys with no sensible mapping.
+pub(super) fn keyval(key: &Key) -> Option<u32> {
+ Some(match key {
+ Key::Character(s) => unicode_keysym(s.chars().next()?),
+ Key::Named(n) => match n {
+ NamedKey::Backspace => keysym::BACKSPACE,
+ NamedKey::Tab => keysym::TAB,
+ NamedKey::Enter => keysym::RETURN,
+ NamedKey::Escape => keysym::ESCAPE,
+ NamedKey::Space => keysym::SPACE,
+ NamedKey::ArrowDown => keysym::DOWN,
+ NamedKey::ArrowLeft => keysym::LEFT,
+ NamedKey::ArrowRight => keysym::RIGHT,
+ NamedKey::ArrowUp => keysym::UP,
+ NamedKey::End => keysym::END,
+ NamedKey::Home => keysym::HOME,
+ NamedKey::PageDown => keysym::PAGE_DOWN,
+ NamedKey::PageUp => keysym::PAGE_UP,
+ NamedKey::Delete => keysym::DELETE,
+ NamedKey::Control => keysym::CONTROL_L,
+ NamedKey::Shift => keysym::SHIFT_L,
+ NamedKey::Alt => keysym::ALT_L,
+ NamedKey::Super => keysym::SUPER_L,
+ NamedKey::F5 => keysym::F5,
+ },
+ })
+}
+
+/// X11 button numbering, which is what WPE expects.
+pub(super) fn button_number(b: MouseButton) -> Option<u32> {
+ Some(match b {
+ MouseButton::Left => 1,
+ MouseButton::Middle => 2,
+ MouseButton::Right => 3,
+ // Back/Forward are chrome navigation in `main.rs`, deliberately not
+ // forwarded to the page.
+ _ => return None,
+ })
+}
+
+pub(super) fn modifiers(ctrl: bool, shift: bool, alt: bool) -> WPEModifiers::Type {
+ let mut m: WPEModifiers::Type = 0;
+ if ctrl {
+ m |= WPEModifiers::WPE_MODIFIER_KEYBOARD_CONTROL;
+ }
+ if shift {
+ m |= WPEModifiers::WPE_MODIFIER_KEYBOARD_SHIFT;
+ }
+ if alt {
+ m |= WPEModifiers::WPE_MODIFIER_KEYBOARD_ALT;
+ }
+ m
+}
+
+pub(super) fn is_pressed(e: &KeyEvent) -> bool {
+ e.state == ElementState::Pressed
+}
+
+/// WPE stamps events with a millisecond clock. It only has to be monotonic
+/// and consistent — `wpe_view_compute_press_count` uses it for double-click
+/// detection, so a frozen value would turn every click into a triple-click.
+pub(super) fn now_ms() -> u32 {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_millis() as u32)
+ .unwrap_or(0)
+}
diff --git a/src/wpe/mod.rs b/src/wpe/mod.rs
new file mode 100644
index 0000000..a3e5459
--- /dev/null
+++ b/src/wpe/mod.rs
@@ -0,0 +1,23 @@
+//! Embedded WPE WebKit for HTML mail (feature `wpe`, on by default).
+//!
+//! `subclass.rs`, `glib_source.rs`, `input.rs` and `wrapper.h` are verbatim
+//! copies of cce-browser's `src/wpe/` — the proven embedding pattern (see
+//! that crate's WPE-PORT.md). Keep them byte-identical to ease a future
+//! extraction into a shared crate; anything mail-specific belongs in
+//! `host.rs`, which replaces the browser's tabbed `WebKitHost` with the
+//! single sandboxed [`host::MailWebView`].
+
+pub mod ffi {
+ #![allow(non_upper_case_globals, non_camel_case_types, non_snake_case, dead_code)]
+ include!(concat!(env!("OUT_DIR"), "/wpe_bindings.rs"));
+}
+
+mod glib_source;
+mod host;
+mod input;
+// dead_code: the copy stays verbatim; mail never pastes into a page, so the
+// browser's clipboard-sync direction goes unused here.
+#[allow(dead_code)]
+mod subclass;
+
+pub use host::MailWebView;
diff --git a/src/wpe/subclass.rs b/src/wpe/subclass.rs
new file mode 100644
index 0000000..d9a994c
--- /dev/null
+++ b/src/wpe/subclass.rs
@@ -0,0 +1,307 @@
+//! The three GObject subclasses WPE requires of an embedder.
+//!
+//! WebKit does not hand us a view to render into; it *asks the display for
+//! one*. So embedding means implementing all three of:
+//!
+//! * `WPEDisplay` — vends the view and the toplevel (`create_view`,
+//! `create_toplevel`). `WebKitWebView`'s `display` property is
+//! construct-only and takes this.
+//! * `WPEToplevel` — **owns buffer-format negotiation.** WebKit asks the
+//! toplevel, not the display. Leave `create_toplevel` NULL and
+//! `render_buffer` silently never fires, with a perfectly healthy web
+//! process and no error anywhere.
+//! * `WPEView` — receives finished frames via `render_buffer`.
+//!
+//! Registration goes through [`register_subclass`] rather than a Rust struct
+//! embedding the parent, because WPE's instance structs are opaque
+//! (`WPE_DECLARE_DERIVABLE_TYPE` typedefs `struct _WPEView` and never defines
+//! it). `g_type_query` reports the parent's sizes at runtime instead, which is
+//! ABI-safe and survives WPE growing a field. The *class* structs are public,
+//! so bindgen lays them out correctly and installing a vfunc is a field set.
+
+use std::ffi::{c_char, c_void, CString};
+
+use super::ffi::*;
+
+/// Register a GObject subclass of `parent`, sized from the runtime type query.
+pub(super) unsafe fn register_subclass(
+ parent: GType,
+ name: &str,
+ class_init: unsafe extern "C" fn(*mut c_void, *mut c_void),
+) -> GType {
+ let mut q: GTypeQuery = std::mem::zeroed();
+ g_type_query(parent, &mut q);
+ assert!(q.type_ != 0, "parent type {name} not registered");
+ let cname = CString::new(name).expect("subclass name");
+ g_type_register_static_simple(
+ parent,
+ cname.as_ptr(),
+ q.class_size,
+ std::mem::transmute::<_, GClassInitFunc>(class_init),
+ q.instance_size,
+ None,
+ 0,
+ )
+}
+
+pub(super) const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
+ (a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
+}
+
+/// Registered once, on first host construction. GType registration is
+/// process-wide and re-registering the same name aborts.
+pub(super) struct Types {
+ pub display: GType,
+ pub view: GType,
+ pub toplevel: GType,
+ pub clipboard: GType,
+}
+
+static mut TYPES: Option<Types> = None;
+
+pub(super) unsafe fn types() -> &'static Types {
+ #[allow(static_mut_refs)]
+ if TYPES.is_none() {
+ TYPES = Some(Types {
+ view: register_subclass(wpe_view_get_type(), "CceWpeView", view_class_init),
+ toplevel: register_subclass(
+ wpe_toplevel_get_type(),
+ "CceWpeToplevel",
+ 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)]
+ TYPES.as_ref().unwrap()
+}
+
+// ---- view ----
+
+/// Set by the host before it creates a webview; `render_buffer` hands frames
+/// here. One host per process for now (see `WebKitHost::new`).
+pub(super) static mut FRAME_SINK: Option<Box<dyn FnMut(*mut WPEBuffer)>> = None;
+
+unsafe extern "C" fn view_render_buffer(
+ view: *mut WPEView,
+ buffer: *mut WPEBuffer,
+ _damage: *const WPERectangle,
+ _n_damage: u32,
+ _error: *mut *mut GError,
+) -> gboolean {
+ #[allow(static_mut_refs)]
+ if let Some(sink) = FRAME_SINK.as_mut() {
+ sink(buffer);
+ }
+ // BOTH halves. `rendered` means displayed, `released` means the memory is
+ // yours again; with only the first the engine produces exactly one frame
+ // and then stalls forever. This is also the backpressure that makes an
+ // unbounded upload queue impossible here.
+ wpe_view_buffer_rendered(view, buffer);
+ wpe_view_buffer_released(view, buffer);
+ 1
+}
+
+unsafe extern "C" fn view_class_init(class: *mut c_void, _data: *mut c_void) {
+ (*(class as *mut WPEViewClass)).render_buffer = Some(view_render_buffer);
+}
+
+// ---- toplevel ----
+
+unsafe extern "C" fn toplevel_formats(_t: *mut WPEToplevel) -> *mut WPEBufferFormats {
+ // Mappable ARGB/XRGB linear: what we can read back on the CPU and hand
+ // straight to `cce_ui::vk::upload_rgba`. DMABuf comes later (phase 2).
+ let b = wpe_buffer_formats_builder_new(std::ptr::null_mut());
+ wpe_buffer_formats_builder_append_group(
+ b,
+ std::ptr::null_mut(),
+ WPEBufferFormatUsage::WPE_BUFFER_FORMAT_USAGE_MAPPING,
+ );
+ for cc in [fourcc(b'A', b'R', b'2', b'4'), fourcc(b'X', b'R', b'2', b'4')] {
+ wpe_buffer_formats_builder_append_format(b, cc, 0);
+ }
+ wpe_buffer_formats_builder_end(b)
+}
+
+unsafe extern "C" fn toplevel_resize(t: *mut WPEToplevel, w: i32, h: i32) -> gboolean {
+ wpe_toplevel_resized(t, w, h);
+ 1
+}
+
+unsafe extern "C" fn toplevel_class_init(class: *mut c_void, _data: *mut c_void) {
+ let c = class as *mut WPEToplevelClass;
+ (*c).get_preferred_buffer_formats = Some(toplevel_formats);
+ (*c).resize = Some(toplevel_resize);
+}
+
+// ---- display ----
+
+unsafe extern "C" fn display_connect(_d: *mut WPEDisplay, _e: *mut *mut GError) -> gboolean {
+ 1
+}
+
+unsafe extern "C" fn display_create_view(d: *mut WPEDisplay) -> *mut WPEView {
+ let prop = CString::new("display").unwrap();
+ g_object_new(types().view, prop.as_ptr(), d, std::ptr::null::<c_char>()) as *mut WPEView
+}
+
+unsafe extern "C" fn display_create_toplevel(
+ d: *mut WPEDisplay,
+ max_views: u32,
+) -> *mut WPEToplevel {
+ let (p1, p2) = (
+ CString::new("display").unwrap(),
+ CString::new("max-views").unwrap(),
+ );
+ g_object_new(
+ types().toplevel,
+ p1.as_ptr(),
+ d,
+ p2.as_ptr(),
+ max_views,
+ std::ptr::null::<c_char>(),
+ ) 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);
+}
diff --git a/src/wpe/wrapper.h b/src/wpe/wrapper.h
new file mode 100644
index 0000000..8eca2e0
--- /dev/null
+++ b/src/wpe/wrapper.h
@@ -0,0 +1,5 @@
+/* Bindgen entry point for the WPE port. The two umbrella headers are the
+ * whole surface: webkit.h is the engine, wpe-platform.h is the embedding
+ * layer (WPEDisplay / WPEView / WPEToplevel) that cce-browser subclasses. */
+#include <wpe/webkit.h>
+#include <wpe/wpe-platform.h>