git.lucas.co / cce-mail
mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git

src/wpe/host.rs (32.7K)

  1 //! `MailWebView` — one sandboxed WPE WebKit view for rendering HTML mail.
  2 //!
  3 //! The mail-shaped sibling of cce-browser's `WebKitHost`: same boot (the
  4 //! GObject subclasses in `subclass.rs`), same frame pipeline (SHM readback →
  5 //! `cce_ui::vk::upload_rgba` → one quad in the detail pane), same calloop
  6 //! bridge (`glib_source.rs`) — but a single view instead of tabs, and locked
  7 //! down for hostile content:
  8 //!
  9 //! * **JavaScript is off.** Mail is not an application platform.
 10 //! * **The network session is ephemeral** — no cookies or cache ever touch
 11 //!   disk.
 12 //! * **All remote loads are blocked by default** by a compiled WebKit content
 13 //!   filter (`data:` and `cid:` stay allowed — a message's own bytes carry
 14 //!   no tracking). Tracking pixels never fire.
 15 //!   [`MailWebView::set_images_allowed`] lifts the filter for the current
 16 //!   message only — an explicit per-message choice, reset on the next
 17 //!   [`MailWebView::load_html`].
 18 //! * **`cid:` inline attachments render natively**: a registered URI scheme
 19 //!   handler serves them from the per-message store filled by
 20 //!   [`MailWebView::set_inline_parts`].
 21 //! * **Navigation never happens in-pane.** A link click is intercepted by
 22 //!   `decide-policy` and handed back through [`MailWebView::take_link_click`]
 23 //!   for the app to open externally; form submissions are dropped.
 24 //!
 25 //! The web process is lazy: constructing the host boots only the WPE display
 26 //! and toplevel (cheap, no child processes). WebKit's processes spawn on the
 27 //! first [`MailWebView::load_html`], so a text-only session pays nothing.
 28 
 29 use std::cell::{Cell, RefCell};
 30 use std::collections::HashMap;
 31 use std::ffi::{c_char, c_void, CString};
 32 use std::rc::Rc;
 33 
 34 use cce_ui::widget::{KeyEvent, MouseButton};
 35 
 36 use super::ffi::*;
 37 use super::glib_source::GlibPoll;
 38 use super::input;
 39 use super::subclass::{types, FRAME_SINK};
 40 
 41 unsafe fn cstr(s: &str) -> CString {
 42     CString::new(s).expect("no interior nul")
 43 }
 44 
 45 unsafe fn from_cstr(p: *const c_char) -> Option<String> {
 46     (!p.is_null())
 47         .then(|| std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned())
 48         .filter(|s| !s.is_empty())
 49 }
 50 
 51 /// Frames handed over by `render_buffer`, drained by `pump`. A slot, not a
 52 /// queue: only the newest frame is worth uploading, and WPE will not produce
 53 /// another until the current one is released anyway.
 54 #[derive(Default)]
 55 struct Pending {
 56     frame: Option<(Vec<u8>, u32, u32)>,
 57 }
 58 
 59 /// The WebKit content filter source: block every URL except `data:` and
 60 /// `cid:`, so a message renders from its own bytes alone — inline
 61 /// attachments carry no tracking, which is why they pass while every
 62 /// remote load waits on the Load Images chip. Compiled once (WebKit caches
 63 /// the compiled form in the store directory, keyed by [`FILTER_ID`]) and
 64 /// attached to the UCM whenever remote content is disallowed.
 65 const BLOCK_REMOTE_FILTER: &str = r#"[
 66   {"trigger": {"url-filter": ".*"}, "action": {"type": "block"}},
 67   {"trigger": {"url-filter": "^data:"}, "action": {"type": "ignore-previous-rules"}},
 68   {"trigger": {"url-filter": "^cid:"}, "action": {"type": "ignore-previous-rules"}}
 69 ]"#;
 70 
 71 /// Bumped whenever [`BLOCK_REMOTE_FILTER`] changes: the store caches the
 72 /// compiled filter under this name, and a new name is cheaper to reason
 73 /// about than trusting it to notice changed source.
 74 const FILTER_ID: &str = "block-remote-v2";
 75 
 76 pub struct MailWebView {
 77     display: *mut WPEDisplay,
 78     toplevel: *mut WPEToplevel,
 79     /// Created on the first `load_html`, kept for the life of the host.
 80     webview: Option<(*mut WebKitWebView, *mut WPEView)>,
 81     session: *mut WebKitNetworkSession,
 82     ucm: *mut WebKitUserContentManager,
 83     /// The compiled block-everything filter; null if compilation failed (in
 84     /// which case remote loads are stopped by `auto-load-images` alone).
 85     filter: *mut WebKitUserContentFilter,
 86     size_px: (u32, u32),
 87     scale: f32,
 88     pending: Rc<RefCell<Pending>>,
 89     /// GLib's pollfd set, mirrored into one epoll fd for calloop.
 90     poll: Option<GlibPoll>,
 91     /// Link URIs the page tried to navigate to, stashed by `decide-policy`.
 92     links: Rc<RefCell<Vec<String>>>,
 93     /// The message currently loaded, kept so lifting the image block can
 94     /// re-render the same content.
 95     html: Option<CString>,
 96     /// The current message's inline attachments, served by the `cid:`
 97     /// scheme handler: content-id → (mime type, decoded bytes).
 98     inline: Rc<RefCell<HashMap<String, (String, Vec<u8>)>>>,
 99     images_allowed: bool,
100     /// Last uploaded frame in the image registry: (id, w px, h px).
101     image: Option<(u32, u32, u32)>,
102     /// The pixels behind [`image`], kept so the frame can be handed to a
103     /// replacement renderer after a reconnect (see [`reupload_frame`]) — and
104     /// so the headless example can assert on rendered output.
105     ///
106     /// [`image`]: MailWebView::image
107     /// [`reupload_frame`]: MailWebView::reupload_frame
108     last_frame: Option<(Vec<u8>, u32, u32)>,
109     /// The pointer buttons the page is holding, as `WPE_MODIFIER_POINTER_*`
110     /// bits, stamped on every pointer event.
111     ///
112     /// WebKit reads a drag off the *move* event's own modifiers, not off the
113     /// press it saw earlier: a move reporting no held button is a hover, so
114     /// press-drag-release over a message selected nothing while every move
115     /// went out with an empty mask.
116     held_buttons: WPEModifiers::Type,
117 }
118 
119 impl Drop for MailWebView {
120     fn drop(&mut self) {
121         unsafe {
122             if let Some((wv, _)) = self.webview.take() {
123                 g_object_unref(wv as *mut _);
124             }
125         }
126         if let Some((id, ..)) = self.image.take() {
127             cce_ui::vk::free_image(id);
128         }
129     }
130 }
131 
132 impl MailWebView {
133     /// Boot WPE (display + toplevel + content filter). One host per process:
134     /// the frame sink and the GType registrations are process-wide.
135     pub fn new(size_px: (u32, u32)) -> Self {
136         unsafe {
137             let t = types();
138             let display = g_object_new(t.display, std::ptr::null::<c_char>()) as *mut WPEDisplay;
139             let mut err: *mut GError = std::ptr::null_mut();
140             assert!(
141                 wpe_display_connect(display, &mut err) != 0,
142                 "wpe_display_connect failed"
143             );
144 
145             // Ephemeral: mail content must leave no cookie jar and no cache.
146             let session = webkit_network_session_new_ephemeral();
147 
148             let pending = Rc::new(RefCell::new(Pending::default()));
149             let sink = pending.clone();
150             FRAME_SINK = Some(Box::new(move |buffer: *mut WPEBuffer| {
151                 if let Some(f) = read_shm(buffer) {
152                     // Replace, never accumulate: the newest frame wins.
153                     sink.borrow_mut().frame = Some(f);
154                 }
155             }));
156 
157             let toplevel = wpe_display_create_toplevel(display, 1);
158             wpe_toplevel_resized(toplevel, size_px.0 as i32, size_px.1 as i32);
159 
160             let filter = compile_block_filter();
161 
162             // The `cid:` scheme, served straight out of the inline store —
163             // the same registration cce-browser uses for its `cce:` pages.
164             // Process-wide and registered once, like the frame sink.
165             let inline: Rc<RefCell<HashMap<String, (String, Vec<u8>)>>> =
166                 Rc::new(RefCell::new(HashMap::new()));
167             let ctx = webkit_web_context_get_default();
168             let scheme = cstr("cid");
169             webkit_web_context_register_uri_scheme(
170                 ctx,
171                 scheme.as_ptr(),
172                 Some(on_cid_request),
173                 Rc::into_raw(inline.clone()) as gpointer,
174                 None,
175             );
176 
177             Self {
178                 display,
179                 toplevel,
180                 webview: None,
181                 session,
182                 ucm: webkit_user_content_manager_new(),
183                 filter,
184                 size_px,
185                 scale: 1.0,
186                 pending,
187                 poll: GlibPoll::new()
188                     .map_err(|e| eprintln!("cce-mail: no GLib epoll bridge ({e}); pump will poll"))
189                     .ok(),
190                 links: Rc::new(RefCell::new(Vec::new())),
191                 html: None,
192                 inline,
193                 images_allowed: false,
194                 image: None,
195                 last_frame: None,
196                 held_buttons: 0,
197             }
198         }
199     }
200 
201     /// The webview, created on first use — this is what spawns WebKit's
202     /// child processes, so it only happens once HTML actually arrives.
203     fn ensure_view(&mut self) -> (*mut WebKitWebView, *mut WPEView) {
204         if let Some(pair) = self.webview {
205             return pair;
206         }
207         unsafe {
208             let (p_display, p_ucm, p_session) = (
209                 cstr("display"),
210                 cstr("user-content-manager"),
211                 cstr("network-session"),
212             );
213             let wv = g_object_new(
214                 webkit_web_view_get_type(),
215                 p_display.as_ptr(),
216                 self.display,
217                 p_ucm.as_ptr(),
218                 self.ucm,
219                 p_session.as_ptr(),
220                 self.session,
221                 std::ptr::null::<c_char>(),
222             ) as *mut WebKitWebView;
223 
224             // The lockdown. JavaScript stays off for the life of the view.
225             // With a compiled filter the image setting stays ON — the filter
226             // is what gates remote loads, and it lets cid:/data: through so
227             // inline attachments always render. Only when the filter failed
228             // to compile does auto-load-images carry the block alone, at the
229             // cost of inline images too (privacy over completeness).
230             let settings = webkit_web_view_get_settings(wv);
231             webkit_settings_set_enable_javascript(settings, 0);
232             webkit_settings_set_auto_load_images(settings, self.images_on() as gboolean);
233             self.apply_filter_policy();
234 
235             // Link clicks leave through the app, never navigate in-pane.
236             let sig = cstr("decide-policy");
237             g_signal_connect_data(
238                 wv as *mut _,
239                 sig.as_ptr(),
240                 Some(std::mem::transmute::<_, unsafe extern "C" fn()>(
241                     on_decide_policy
242                         as unsafe extern "C" fn(
243                             *mut WebKitWebView,
244                             *mut WebKitPolicyDecision,
245                             WebKitPolicyDecisionType::Type,
246                             gpointer,
247                         ) -> gboolean,
248                 )),
249                 Rc::into_raw(self.links.clone()) as gpointer,
250                 Some(drop_links_ref),
251                 0,
252             );
253 
254             let view = webkit_web_view_get_wpe_view(wv);
255             wpe_view_set_toplevel(view, self.toplevel);
256             let (lw, lh) = self.logical_size();
257             wpe_view_resized(view, lw, lh);
258             wpe_view_set_visible(view, 1);
259             wpe_view_map(view);
260             // Without focus the page has no focused frame and forwarded
261             // keyboard input (PageDown, Ctrl+C) is silently dropped.
262             wpe_view_focus_in(view);
263             self.webview = Some((wv, view));
264             (wv, view)
265         }
266     }
267 
268     /// Attach or detach the block-everything filter to match
269     /// `images_allowed`. WebKit applies UCM changes to live pages.
270     fn apply_filter_policy(&self) {
271         if self.filter.is_null() {
272             return;
273         }
274         unsafe {
275             // Cleared before re-adding either way: the block branch runs
276             // on every message load, filter already installed or not.
277             webkit_user_content_manager_remove_all_filters(self.ucm);
278             if !self.images_allowed {
279                 webkit_user_content_manager_add_filter(self.ucm, self.filter);
280             }
281         }
282     }
283 
284     /// Show a message. Always re-arms the remote-content block: allowing
285     /// images is a per-message decision, never a sticky one. The filter
286     /// goes back on here too — resetting the flag alone left the block
287     /// lifted for every message after the first "Load Images".
288     pub fn load_html(&mut self, html: &str) {
289         self.images_allowed = false;
290         self.apply_filter_policy();
291         // NUL bytes would truncate the CString; they carry no meaning in
292         // HTML, so strip rather than fail.
293         let owned;
294         let clean = if html.contains('\0') {
295             owned = html.replace('\0', "");
296             owned.as_str()
297         } else {
298             html
299         };
300         self.html = Some(unsafe { cstr(clean) });
301         self.reload_current();
302     }
303 
304     /// Install the message's inline attachments for the `cid:` handler,
305     /// replacing the previous message's. Call BEFORE `load_html`, or the
306     /// page's image requests race the store swap.
307     pub fn set_inline_parts(&mut self, parts: Vec<(String, String, Vec<u8>)>) {
308         let mut store = self.inline.borrow_mut();
309         store.clear();
310         for (cid, mime, bytes) in parts {
311             store.insert(cid, (mime, bytes));
312         }
313     }
314 
315     /// Drop the shown message (selection cleared / folder switched). The
316     /// view and its processes stay for the next message.
317     pub fn clear(&mut self) {
318         self.html = None;
319         self.inline.borrow_mut().clear();
320         self.links.borrow_mut().clear();
321         self.pending.borrow_mut().frame = None;
322         if let Some((id, ..)) = self.image.take() {
323             cce_ui::vk::free_image(id);
324         }
325         if let Some((wv, _)) = self.webview {
326             unsafe {
327                 let blank = cstr("about:blank");
328                 webkit_web_view_load_uri(wv, blank.as_ptr());
329             }
330         }
331     }
332 
333     /// Lift (or restore) the remote-content block for the current message
334     /// and re-render it.
335     pub fn set_images_allowed(&mut self, allowed: bool) {
336         if allowed == self.images_allowed {
337             return;
338         }
339         self.images_allowed = allowed;
340         self.apply_filter_policy();
341         self.reload_current();
342     }
343 
344     pub fn images_allowed(&self) -> bool {
345         self.images_allowed
346     }
347 
348     /// Whether WebKit's own image loading is on — see `ensure_view` for why
349     /// this is not simply `images_allowed`.
350     fn images_on(&self) -> bool {
351         self.images_allowed || !self.filter.is_null()
352     }
353 
354     fn reload_current(&mut self) {
355         let Some(html) = self.html.clone() else { return };
356         let images_on = self.images_on();
357         let (wv, _) = self.ensure_view();
358         unsafe {
359             let settings = webkit_web_view_get_settings(wv);
360             webkit_settings_set_auto_load_images(settings, images_on as gboolean);
361             webkit_web_view_load_html(wv, html.as_ptr(), std::ptr::null());
362         }
363         // The old message's frame must not linger under the new one — the
364         // app falls back to the text body until the first frame lands.
365         self.pending.borrow_mut().frame = None;
366         if let Some((id, ..)) = self.image.take() {
367             cce_ui::vk::free_image(id);
368         }
369     }
370 
371     /// A link the user clicked in the message, if any (FIFO).
372     pub fn take_link_click(&self) -> Option<String> {
373         let mut links = self.links.borrow_mut();
374         (!links.is_empty()).then(|| links.remove(0))
375     }
376 
377     /// The epoll fd carrying GLib's pollfd set, duplicated for calloop.
378     /// `None` if the bridge could not be created — fall back to the timer.
379     pub fn poll_fd_owned(&self) -> Option<std::os::fd::OwnedFd> {
380         let fd = self.poll.as_ref()?.fd();
381         rustix::io::dup(fd).ok()
382     }
383 
384     /// How long calloop may sleep before pumping anyway, per GLib.
385     pub fn poll_timeout(&self) -> Option<std::time::Duration> {
386         self.poll
387             .as_ref()
388             .and_then(|p| p.timeout)
389             .map(|ms| std::time::Duration::from_millis(ms as u64))
390     }
391 
392     /// Drain GLib's pending work, then upload any frame it produced.
393     /// Returns true when a new frame landed (the pane needs a repaint).
394     pub fn pump(&mut self) -> bool {
395         // Clear the inner epoll first: calloop is level-triggered on that fd,
396         // so leaving it readable across a pump that does not consume the
397         // underlying socket would spin the loop.
398         if let Some(p) = &self.poll {
399             p.drain();
400         }
401         unsafe {
402             while g_main_context_iteration(std::ptr::null_mut(), 0) != 0 {}
403         }
404         // WebKit opens and drops sockets as it loads, so the set that matters
405         // is the one *after* dispatch, not before.
406         if let Some(p) = &mut self.poll {
407             p.sync();
408         }
409         let Some((px, w, h)) = self.pending.borrow_mut().frame.take() else {
410             return false;
411         };
412         self.last_frame = Some((px.clone(), w, h));
413         let id = cce_ui::vk::upload_rgba(px, w, h);
414         if let Some((old, ..)) = self.image.replace((id, w, h)) {
415             cce_ui::vk::free_image(old);
416         }
417         true
418     }
419 
420     /// The current frame in the image registry: (id, w px, h px).
421     pub fn image(&self) -> Option<(u32, u32, u32)> {
422         self.image
423     }
424 
425     /// Hand the last frame to a renderer that has just replaced the one it
426     /// was uploaded to. Returns true when the pane should repaint.
427     ///
428     /// An image id belongs to a **renderer**, and a renderer does not outlive
429     /// its session: `cce-ui`'s `window_runner` repairs a lost Wayland
430     /// transport by opening a new session around the same `Application`,
431     /// which rebuilds the renderer and with it the image table. A draw for an
432     /// unknown id is skipped rather than reported, and `self.image` is only
433     /// replaced when WPE produces a NEW frame — so a message that had
434     /// finished loading (the normal case: a page is painted once and then sits
435     /// there) would show an empty detail pane until something forced a
436     /// reload.
437     ///
438     /// Re-uploading the pixels beats re-rendering: no WPE round trip, no
439     /// refetch of remote content, and the pane comes back on the very next
440     /// frame. The new id is written to `self.image`, the field `pump` owns, so
441     /// the next real frame still frees the right one.
442     pub fn reupload_frame(&mut self) -> bool {
443         if let Some((old, ..)) = self.image.take() {
444             // A free for an id the new renderer never had is a no-op, and ids
445             // are process-unique, so this cannot reach a live image.
446             cce_ui::vk::free_image(old);
447         }
448         let Some((px, w, h)) = self.last_frame.clone() else { return false };
449         self.image = Some((cce_ui::vk::upload_rgba(px, w, h), w, h));
450         true
451     }
452 
453     /// A pixel of the last frame, for tests asserting on rendered output
454     /// (examples/wpe_mail.rs; dead in the app build).
455     #[allow(dead_code)]
456     pub fn sample_pixel(&self, x: u32, y: u32) -> Option<(u8, u8, u8)> {
457         let (px, w, h) = self.last_frame.as_ref()?;
458         if x >= *w || y >= *h {
459             return None;
460         }
461         let i = ((y * w + x) * 4) as usize;
462         Some((px[i], px[i + 1], px[i + 2]))
463     }
464 
465     /// Put the page's current selection on the system clipboard (the
466     /// clipboard subclass routes it through cce-ui's wl-copy helper).
467     pub fn copy_selection(&self) {
468         if let Some((wv, _)) = self.webview {
469             unsafe {
470                 let c = cstr("Copy");
471                 webkit_web_view_execute_editing_command(wv, c.as_ptr());
472             }
473         }
474     }
475 
476     // ---- input ----
477     //
478     // Coordinates are device pixels relative to the view origin (the
479     // browser's convention); the host converts to WPE's logical space.
480 
481     pub fn mouse_move(&mut self, x_px: f32, y_px: f32) {
482         let Some((_, view)) = self.webview else { return };
483         unsafe {
484             let (x, y) = self.to_logical(x_px, y_px);
485             let e = wpe_event_pointer_move_new(
486                 WPEEventType::WPE_EVENT_POINTER_MOVE,
487                 view,
488                 WPEInputSource::WPE_INPUT_SOURCE_MOUSE,
489                 input::now_ms(),
490                 self.held_buttons,
491                 x,
492                 y,
493                 0.0,
494                 0.0,
495             );
496             self.send(view, e);
497         }
498     }
499 
500     pub fn mouse_button_ui(&mut self, button: MouseButton, pressed: bool, x_px: f32, y_px: f32) {
501         let Some(n) = input::button_number(button) else {
502             return;
503         };
504         let Some((_, view)) = self.webview else { return };
505         unsafe {
506             let time = input::now_ms();
507             let (x, y) = self.to_logical(x_px, y_px);
508             // WPE tracks double/triple clicks for us; a frozen clock here
509             // would make every click read as a repeat.
510             let press_count = if pressed {
511                 wpe_view_compute_press_count(view, x, y, n, time)
512             } else {
513                 0
514             };
515             // The mask describes the state *after* this event, which is
516             // what a DOM `buttons` reads on mousedown and mouseup.
517             let bit = input::button_modifier(n);
518             if pressed {
519                 self.held_buttons |= bit;
520             } else {
521                 self.held_buttons &= !bit;
522             }
523             let e = wpe_event_pointer_button_new(
524                 if pressed {
525                     WPEEventType::WPE_EVENT_POINTER_DOWN
526                 } else {
527                     WPEEventType::WPE_EVENT_POINTER_UP
528                 },
529                 view,
530                 WPEInputSource::WPE_INPUT_SOURCE_MOUSE,
531                 time,
532                 self.held_buttons,
533                 n,
534                 x,
535                 y,
536                 press_count,
537             );
538             self.send(view, e);
539         }
540     }
541 
542     /// Wheel deltas in device pixels, winit-signed (positive = up), passed
543     /// through unchanged — WPE inverts on the way to the DOM itself.
544     pub fn wheel(&mut self, dx_px: f64, dy_px: f64, x_px: f32, y_px: f32) {
545         let Some((_, view)) = self.webview else { return };
546         unsafe {
547             let (x, y) = self.to_logical(x_px, y_px);
548             let e = wpe_event_scroll_new(
549                 view,
550                 WPEInputSource::WPE_INPUT_SOURCE_MOUSE,
551                 input::now_ms(),
552                 0,
553                 dx_px / self.scale as f64,
554                 dy_px / self.scale as f64,
555                 1, // precise deltas: these are pixels, not notches
556                 0, // not a scroll-stop event
557                 x,
558                 y,
559             );
560             self.send(view, e);
561         }
562     }
563 
564     /// Forward a cce-ui key event (page scrolling, copy chords).
565     pub fn key_ui(&mut self, event: &KeyEvent) {
566         let Some(keyval) = input::keyval(&event.logical_key) else {
567             return;
568         };
569         let Some((_, view)) = self.webview else { return };
570         let pressed = input::is_pressed(event);
571         unsafe {
572             let e = wpe_event_keyboard_new(
573                 if pressed {
574                     WPEEventType::WPE_EVENT_KEYBOARD_KEY_DOWN
575                 } else {
576                     WPEEventType::WPE_EVENT_KEYBOARD_KEY_UP
577                 },
578                 view,
579                 WPEInputSource::WPE_INPUT_SOURCE_KEYBOARD,
580                 input::now_ms(),
581                 input::modifiers(event.ctrl, event.shift, event.alt),
582                 0, // hardware keycode: unknown to us, WebKit works off keyval
583                 keyval,
584             );
585             self.send(view, e);
586         }
587     }
588 
589     unsafe fn send(&self, view: *mut WPEView, event: *mut WPEEvent) {
590         if event.is_null() {
591             return;
592         }
593         wpe_view_event(view, event);
594         wpe_event_unref(event);
595     }
596 
597     /// Resize, in **physical** pixels plus the scale. WPE wants a logical
598     /// size and produces a buffer of `size * scale` — handing it physical
599     /// pixels at scale 1 would lay out double-width CSS on a 2x display.
600     pub fn resize(&mut self, width_px: u32, height_px: u32, scale: f32) {
601         let size = (width_px.max(1), height_px.max(1));
602         let scale = scale.max(0.01);
603         if size == self.size_px && (scale - self.scale).abs() < 1.0e-3 {
604             return;
605         }
606         self.size_px = size;
607         self.scale = scale;
608         let (lw, lh) = self.logical_size();
609         unsafe {
610             wpe_toplevel_scale_changed(self.toplevel, self.scale as f64);
611             wpe_toplevel_resized(self.toplevel, lw, lh);
612             if let Some((_, view)) = self.webview {
613                 wpe_view_resized(view, lw, lh);
614             }
615         }
616     }
617 
618     /// The view size WPE works in: physical divided back out by the scale.
619     fn logical_size(&self) -> (i32, i32) {
620         (
621             ((self.size_px.0 as f32 / self.scale).round() as i32).max(1),
622             ((self.size_px.1 as f32 / self.scale).round() as i32).max(1),
623         )
624     }
625 
626     fn to_logical(&self, x_px: f32, y_px: f32) -> (f64, f64) {
627         ((x_px / self.scale) as f64, (y_px / self.scale) as f64)
628     }
629 }
630 
631 /// Compile (or load from WebKit's cache) the block-remote content filter.
632 ///
633 /// The store API is async; the surrounding code is a constructor with a GLib
634 /// context and nothing else running on it yet, so this blocks on bounded
635 /// context iterations until the callback lands. Null on failure — the caller
636 /// degrades to `auto-load-images` alone.
637 unsafe fn compile_block_filter() -> *mut WebKitUserContentFilter {
638     struct Slot {
639         done: Cell<bool>,
640         filter: Cell<*mut WebKitUserContentFilter>,
641     }
642     unsafe extern "C" fn on_saved(source: *mut GObject, res: *mut GAsyncResult, data: gpointer) {
643         let slot = &*(data as *const Slot);
644         let mut err: *mut GError = std::ptr::null_mut();
645         let f = webkit_user_content_filter_store_save_finish(
646             source as *mut WebKitUserContentFilterStore,
647             res,
648             &mut err,
649         );
650         if f.is_null() {
651             let msg = (!err.is_null())
652                 .then(|| from_cstr((*err).message))
653                 .flatten()
654                 .unwrap_or_else(|| "unknown error".into());
655             eprintln!("cce-mail: content filter failed to compile ({msg})");
656             if !err.is_null() {
657                 g_error_free(err);
658             }
659         }
660         slot.filter.set(f);
661         slot.done.set(true);
662     }
663 
664     // Absolute unless neither $XDG_STATE_HOME nor $HOME is set.
665     let dir = Some(cce_ui::config::cce_state_dir().join("mail/content-filters")).filter(|d| d.is_absolute());
666     let Some(dir) = dir else {
667         eprintln!("cce-mail: no HOME; remote-content filter disabled");
668         return std::ptr::null_mut();
669     };
670     let _ = std::fs::create_dir_all(&dir);
671 
672     let cdir = cstr(&dir.to_string_lossy());
673     let store = webkit_user_content_filter_store_new(cdir.as_ptr());
674     let id = cstr(FILTER_ID);
675     let bytes = g_bytes_new(
676         BLOCK_REMOTE_FILTER.as_ptr() as *const c_void,
677         BLOCK_REMOTE_FILTER.len() as u64,
678     );
679     let slot = Box::new(Slot {
680         done: Cell::new(false),
681         filter: Cell::new(std::ptr::null_mut()),
682     });
683     webkit_user_content_filter_store_save(
684         store,
685         id.as_ptr(),
686         bytes,
687         std::ptr::null_mut(),
688         Some(on_saved),
689         slot.as_ref() as *const Slot as gpointer,
690     );
691     // Blocking iterations; the cap turns a wedged store into a filterless
692     // start instead of a hang.
693     for _ in 0..10_000 {
694         if slot.done.get() {
695             break;
696         }
697         g_main_context_iteration(std::ptr::null_mut(), 1);
698     }
699     g_bytes_unref(bytes);
700     g_object_unref(store as *mut _);
701     if !slot.done.get() {
702         eprintln!("cce-mail: content filter compile timed out; remote loads gated by image setting only");
703         // The callback may still fire later against the leaked slot.
704         Box::leak(slot);
705         return std::ptr::null_mut();
706     }
707     slot.filter.get()
708 }
709 
710 unsafe extern "C" fn drop_links_ref(data: gpointer, _c: *mut GClosure) {
711     drop(Rc::from_raw(data as *const RefCell<Vec<String>>));
712 }
713 
714 /// Minimal %XX decoding for the `cid:` URI path — Content-IDs are almost
715 /// always plain, but `@` does arrive as `%40` from some composers.
716 fn percent_decode_bytes(s: &str) -> String {
717     let bytes = s.as_bytes();
718     let mut out = Vec::with_capacity(bytes.len());
719     let mut i = 0;
720     while i < bytes.len() {
721         if bytes[i] == b'%' {
722             if let (Some(h), Some(l)) = (
723                 bytes.get(i + 1).and_then(|b| (*b as char).to_digit(16)),
724                 bytes.get(i + 2).and_then(|b| (*b as char).to_digit(16)),
725             ) {
726                 out.push((h * 16 + l) as u8);
727                 i += 3;
728                 continue;
729             }
730         }
731         out.push(bytes[i]);
732         i += 1;
733     }
734     String::from_utf8_lossy(&out).into_owned()
735 }
736 
737 /// Serves the page's `cid:` image requests from the inline store. Runs on
738 /// the main thread (the cce-browser `cce:` handler's contract). A cid the
739 /// message structure did not carry answers with an error — a broken-image
740 /// glyph, never a network fetch.
741 unsafe extern "C" fn on_cid_request(request: *mut WebKitURISchemeRequest, data: gpointer) {
742     let store = &*(data as *const RefCell<HashMap<String, (String, Vec<u8>)>>);
743     let uri = from_cstr(webkit_uri_scheme_request_get_uri(request)).unwrap_or_default();
744     let cid = percent_decode_bytes(uri.strip_prefix("cid:").unwrap_or(""));
745     match store.borrow().get(&cid) {
746         Some((mime, bytes)) => {
747             // g_bytes_new copies; the stream owns that copy outright.
748             let gb = g_bytes_new(bytes.as_ptr() as *const c_void, bytes.len() as u64);
749             let stream = g_memory_input_stream_new_from_bytes(gb);
750             let ctype = cstr(mime);
751             webkit_uri_scheme_request_finish(request, stream, bytes.len() as i64, ctype.as_ptr());
752             g_bytes_unref(gb);
753             g_object_unref(stream as *mut _);
754         }
755         None => {
756             let msg = cstr(&format!("no inline part for cid:{cid}"));
757             let err = g_error_new_literal(1, 0, msg.as_ptr());
758             webkit_uri_scheme_request_finish_error(request, err);
759             g_error_free(err);
760         }
761     }
762 }
763 
764 /// Every navigation decision. The initial `load_html` arrives as type OTHER
765 /// and passes; a clicked link is stashed for external opening; everything
766 /// else (forms, window.open targets) is refused outright.
767 unsafe extern "C" fn on_decide_policy(
768     _wv: *mut WebKitWebView,
769     decision: *mut WebKitPolicyDecision,
770     kind: WebKitPolicyDecisionType::Type,
771     data: gpointer,
772 ) -> gboolean {
773     let links = &*(data as *const RefCell<Vec<String>>);
774     match kind {
775         WebKitPolicyDecisionType::WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION
776         | WebKitPolicyDecisionType::WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION => {
777             let nav = decision as *mut WebKitNavigationPolicyDecision;
778             let action = webkit_navigation_policy_decision_get_navigation_action(nav);
779             let ty = webkit_navigation_action_get_navigation_type(action);
780             let is_click = ty == WebKitNavigationType::WEBKIT_NAVIGATION_TYPE_LINK_CLICKED;
781             let in_new_window =
782                 kind == WebKitPolicyDecisionType::WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION;
783             if is_click || in_new_window {
784                 let req = webkit_navigation_action_get_request(action);
785                 if let Some(uri) = from_cstr(webkit_uri_request_get_uri(req)) {
786                     links.borrow_mut().push(uri);
787                 }
788                 webkit_policy_decision_ignore(decision);
789             } else if ty == WebKitNavigationType::WEBKIT_NAVIGATION_TYPE_OTHER {
790                 // The app's own load_html / about:blank clears.
791                 webkit_policy_decision_use(decision);
792             } else {
793                 // Form submits, reloads, back/forward: nothing a mail pane
794                 // should ever do.
795                 webkit_policy_decision_ignore(decision);
796             }
797         }
798         _ => {
799             webkit_policy_decision_use(decision);
800         }
801     }
802     1
803 }
804 
805 /// Copy an SHM buffer's pixels out as RGBA for `upload_rgba`.
806 ///
807 /// `WPE_PIXEL_FORMAT_ARGB8888` is B,G,R,A in memory on little-endian, and the
808 /// stride is not assumed to equal `width * 4`.
809 unsafe fn read_shm(buffer: *mut WPEBuffer) -> Option<(Vec<u8>, u32, u32)> {
810     if g_type_check_instance_is_a(buffer as *mut GTypeInstance, wpe_buffer_shm_get_type()) == 0 {
811         return None;
812     }
813     let shm = buffer as *mut WPEBufferSHM;
814     let (w, h) = (
815         wpe_buffer_get_width(buffer) as u32,
816         wpe_buffer_get_height(buffer) as u32,
817     );
818     let mut len: u64 = 0;
819     let src = g_bytes_get_data(wpe_buffer_shm_get_data(shm), &mut len as *mut u64) as *const u8;
820     if src.is_null() || w == 0 || h == 0 {
821         return None;
822     }
823     let stride = wpe_buffer_shm_get_stride(shm) as usize;
824     let mut out = vec![0u8; (w * h * 4) as usize];
825     for y in 0..h as usize {
826         for x in 0..w as usize {
827             let s = src.add(y * stride + x * 4);
828             let d = (y * w as usize + x) * 4;
829             out[d] = *s.add(2);
830             out[d + 1] = *s.add(1);
831             out[d + 2] = *s;
832             out[d + 3] = *s.add(3);
833         }
834     }
835     Some((out, w, h))
836 }