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

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