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 }