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 }