mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git
feat: cid: inline images render in the HTML view
Inline attachments (embedded logos, signature images) now display instead
of breaking. Three pieces:
- A 'cid:' URI scheme handler on the WebKit context (cce-browser's cce:
registration pattern) serves image bytes from a per-message store the
app fills before each load_html.
- The content filter allows '^cid:' through alongside '^data:', and
auto-load-images now stays ON whenever the filter compiled — the filter
alone gates remote loads, so inline images (a message's own bytes, no
tracking) always render while remote ones still wait on the Load Images
chip. Only when the filter failed to compile does auto-load-images carry
the block alone, at the cost of inline images too. The filter store id
is bumped (block-remote-v2) rather than trusting the cache to notice
changed source.
- The on-demand HTML fetch walks the same BODYSTRUCTURE for parts with a
Content-ID and fetches only those the HTML references, capped per part
and per message; HtmlFetched now carries an HtmlMail (html + inline
parts) and the cache keys that.
Verified headless in examples/wpe_mail.rs (green cid image asserted by
pixel — with the lesson baked into a comment: the wait condition must
check all three channels, because white satisfies g>150 and an early
sample reads the pre-image frame) and in-app via the CCE_MAIL_HTML_DEMO
hook, whose demo message now carries a cid logo.
Co-Authored-By: Claude Fable 5 <[email protected]>
examples/wpe_mail.rs | 39 +++++++++
src/main.rs | 229 +++++++++++++++++++++++++++++++++++++++++++++++----
src/wpe/host.rs | 133 ++++++++++++++++++++++++++----
3 files changed, 370 insertions(+), 31 deletions(-)
diff --git a/examples/wpe_mail.rs b/examples/wpe_mail.rs
index 7f505cd..b4c23a7 100644
--- a/examples/wpe_mail.rs
+++ b/examples/wpe_mail.rs
@@ -39,4 +39,43 @@ fn main() {
println!("center pixel: ({r},{g},{b})");
assert!(r > 180 && g < 80 && b < 80, "expected the red body, got ({r},{g},{b})");
println!("OK: {frames} frames, red body rendered, remote pixel blocked from loading");
+
+ // Phase 2: a cid: inline image, served by the registered scheme handler
+ // from the per-message store — full-bleed green, so the center pixel
+ // proves the request went store → stream → raster.
+ let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><rect width="200" height="200" fill="#00c000"/></svg>"##;
+ view.set_inline_parts(vec![(
+ "logo@test".to_string(),
+ "image/svg+xml".to_string(),
+ svg.to_vec(),
+ )]);
+ view.load_html(
+ r#"<html><body style="margin:0"><img src="cid:logo@test" style="display:block;width:800px;height:600px"></body></html>"#,
+ );
+ let mut cid_frames = 0;
+ for i in 0..120 {
+ if view.pump() {
+ cid_frames += 1;
+ println!(
+ "t={:>5}ms cid frame#{cid_frames} px@center={:?}",
+ i * 50,
+ view.sample_pixel(400, 300)
+ );
+ }
+ std::thread::sleep(std::time::Duration::from_millis(50));
+ // Wait for the frame that actually shows the image, not the first
+ // paint before the subresource arrived. All three channels: white
+ // (the pre-image paint) also has a green channel over 150.
+ if cid_frames > 0 {
+ if let Some((r, g, b)) = view.sample_pixel(400, 300) {
+ if g > 150 && r < 80 && b < 80 {
+ break;
+ }
+ }
+ }
+ }
+ let (r, g, b) = view.sample_pixel(400, 300).expect("no readback");
+ println!("cid image center pixel: ({r},{g},{b})");
+ assert!(g > 150 && r < 80 && b < 80, "expected the green cid image, got ({r},{g},{b})");
+ println!("OK: cid inline image rendered through the scheme handler");
}
diff --git a/src/main.rs b/src/main.rs
index da99602..10a68c2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -127,10 +127,20 @@ enum AppMessage {
#[cfg(feature = "wpe")]
Spin,
/// The on-demand HTML part fetch for email `0` finished: `Some` carries
- /// the decoded HTML, `None` means no HTML part (or the fetch failed) —
- /// the pane stays on the text body.
+ /// the decoded HTML plus its inline images, `None` means no HTML part
+ /// (or the fetch failed) — the pane stays on the text body.
#[cfg(feature = "wpe")]
- HtmlFetched(usize, Option<String>),
+ HtmlFetched(usize, Option<HtmlMail>),
+}
+
+/// One message's renderable HTML: the decoded body plus the cid: inline
+/// attachments it references — (content-id, mime type, decoded bytes),
+/// served to the page by the webview's `cid:` scheme handler.
+#[cfg(feature = "wpe")]
+#[derive(Debug, Clone)]
+struct HtmlMail {
+ html: String,
+ inline: Vec<(String, String, Vec<u8>)>,
}
/// The single status slot at the bottom of the window. Info toasts count
@@ -259,7 +269,7 @@ struct ClearEmailApp {
#[cfg(feature = "wpe")]
html_pending: Option<usize>,
#[cfg(feature = "wpe")]
- html_cache: std::collections::HashMap<usize, String>,
+ html_cache: std::collections::HashMap<usize, HtmlMail>,
/// The user asked for the text body of the current message.
#[cfg(feature = "wpe")]
show_text: bool,
@@ -689,6 +699,14 @@ const PART_FETCH_CAP: u32 = 65536;
#[cfg(feature = "wpe")]
const HTML_FETCH_CAP: u32 = 1_048_576;
+/// Caps on the cid: inline images fetched alongside the HTML: per part
+/// (pre-decode) and summed per message. A signature logo is kilobytes; a
+/// message inlining more than this renders what fits and the rest break.
+#[cfg(feature = "wpe")]
+const INLINE_PART_CAP: u32 = 2_097_152;
+#[cfg(feature = "wpe")]
+const INLINE_TOTAL_CAP: usize = 8 * 1024 * 1024;
+
/// In-memory HTML bodies kept for re-opening without a refetch. Bodies can
/// be large, so the cache is small and simply dumped when full.
#[cfg(feature = "wpe")]
@@ -826,6 +844,67 @@ fn find_html_part(bs: &imap_proto::types::BodyStructure<'_>) -> Option<TextPartS
best
}
+/// A part carrying a Content-ID — a candidate for the HTML body's `cid:`
+/// references. Whether it is actually fetched depends on the HTML: only
+/// referenced parts are worth the round trip.
+#[cfg(feature = "wpe")]
+#[derive(Debug, Clone, PartialEq)]
+struct InlinePartSpec {
+ /// Content-ID with the RFC 2392 angle brackets stripped — the form a
+ /// `cid:` URI names it by.
+ cid: String,
+ section: Vec<u32>,
+ encoding: String,
+ mime: String,
+}
+
+/// DFS over a BODYSTRUCTURE for every part with a Content-ID, in the same
+/// section-numbering scheme as the other walks.
+#[cfg(feature = "wpe")]
+fn find_inline_parts(bs: &imap_proto::types::BodyStructure<'_>) -> Vec<InlinePartSpec> {
+ use imap_proto::types::BodyStructure as B;
+ fn push(
+ common: &imap_proto::types::BodyContentCommon<'_>,
+ other: &imap_proto::types::BodyContentSinglePart<'_>,
+ path: &[u32],
+ out: &mut Vec<InlinePartSpec>,
+ ) {
+ let Some(id) = &other.id else { return };
+ let cid = id.trim().trim_start_matches('<').trim_end_matches('>').to_string();
+ if cid.is_empty() {
+ return;
+ }
+ out.push(InlinePartSpec {
+ cid,
+ section: if path.is_empty() { vec![1] } else { path.to_vec() },
+ encoding: encoding_str(&other.transfer_encoding),
+ mime: format!(
+ "{}/{}",
+ common.ty.ty.to_ascii_lowercase(),
+ common.ty.subtype.to_ascii_lowercase()
+ ),
+ });
+ }
+ fn walk(bs: &B<'_>, path: &mut Vec<u32>, out: &mut Vec<InlinePartSpec>) {
+ match bs {
+ B::Basic { common, other, .. } | B::Text { common, other, .. } => {
+ push(common, other, path, out)
+ }
+ B::Multipart { bodies, .. } => {
+ for (i, b) in bodies.iter().enumerate() {
+ path.push(i as u32 + 1);
+ walk(b, path, out);
+ path.pop();
+ }
+ }
+ _ => {}
+ }
+ }
+ let mut out = Vec::new();
+ walk(bs, &mut Vec::new(), &mut out);
+ out
+}
+
/// One attachment as the server describes it: everything needed to list it
/// in the detail pane and to fetch exactly that part on demand.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -2197,7 +2276,7 @@ fn fetch_html_part(
std::thread::spawn(move || {
// Falling back to text is silent in the UI by design, but the WHY
// must not vanish with it — stderr, like every other failure here.
- let report = |r: Option<String>, why: &str| {
+ let report = |r: Option<HtmlMail>, why: &str| {
if r.is_none() {
eprintln!("cce-mail: html fetch uid {uid} in {mailbox:?}: {why}");
}
@@ -2223,11 +2302,13 @@ fn fetch_html_part(
let _ = session.logout();
return;
}
- let spec = match session.uid_fetch(uid.to_string(), "(BODYSTRUCTURE)") {
+ // One structure fetch feeds both walks: the html part to render and
+ // the Content-ID parts its `cid:` references may name.
+ let parsed = match session.uid_fetch(uid.to_string(), "(BODYSTRUCTURE)") {
Ok(fetches) => match fetches.iter().next() {
Some(f) => match f.bodystructure() {
Some(bs) => match find_html_part(bs) {
- Some(spec) => Ok(spec),
+ Some(spec) => Ok((spec, find_inline_parts(bs))),
None => Err("no text/html part in the structure".to_string()),
},
None => Err("fetch reply carried no BODYSTRUCTURE".to_string()),
@@ -2236,8 +2317,8 @@ fn fetch_html_part(
},
Err(e) => Err(format!("BODYSTRUCTURE fetch failed: {e}")),
};
- let spec = match spec {
- Ok(spec) => spec,
+ let (spec, inline_specs) = match parsed {
+ Ok(parts) => parts,
Err(why) => {
report(None, &why);
let _ = session.logout();
@@ -2260,7 +2341,45 @@ fn fetch_html_part(
Err(e) => Err(format!("part fetch failed: {e}")),
};
match html {
- Ok(html) => report(Some(html), ""),
+ Ok(html) => {
+ // Only the inline parts the HTML actually names are worth a
+ // round trip; the rest are ordinary attachments to the chips.
+ let mut inline = Vec::new();
+ let mut total = 0usize;
+ for p in inline_specs {
+ if !html.contains(&p.cid) {
+ continue;
+ }
+ if total >= INLINE_TOTAL_CAP {
+ eprintln!(
+ "cce-mail: html fetch uid {uid}: inline images over {INLINE_TOTAL_CAP} bytes; the rest will show broken"
+ );
+ break;
+ }
+ let query = format!(
+ "(UID BODY.PEEK[{}]<0.{}>)",
+ section_str(&p.section),
+ INLINE_PART_CAP
+ );
+ let section_path =
+ imap_proto::types::SectionPath::Part(p.section.clone(), None);
+ match session.uid_fetch(uid.to_string(), &query) {
+ Ok(fetches) => {
+ if let Some(bytes) =
+ fetches.iter().next().and_then(|f| f.section(§ion_path))
+ {
+ let decoded = decode_part_bytes(&p.encoding, bytes);
+ total += decoded.len();
+ inline.push((p.cid, p.mime, decoded));
+ }
+ }
+ Err(e) => {
+ eprintln!("cce-mail: html fetch uid {uid}: inline part {} failed: {e}", p.cid)
+ }
+ }
+ }
+ report(Some(HtmlMail { html, inline }), "");
+ }
Err(why) => report(None, &why),
}
let _ = session.logout();
@@ -3038,9 +3157,9 @@ impl ClearEmailApp {
if self.html_loaded.take().is_some() {
self.webview.clear();
}
- if let Some(html) = self.html_cache.get(&id) {
- let html = html.clone();
- self.webview.load_html(&html);
+ if let Some(mail) = self.html_cache.get(&id) {
+ let mail = mail.clone();
+ self.show_html_mail(&mail);
self.html_loaded = Some(id);
return;
}
@@ -3056,12 +3175,22 @@ impl ClearEmailApp {
if let Some(email) = self.emails.iter().find(|e| e.id == id) {
let html = format!(
"<!doctype html><html><body style=\"font-family:sans-serif;margin:16px\">\
+ <img src=\"cid:demo-logo@cce-mail\" width=\"48\" height=\"48\" style=\"float:right\">\
<h2 style=\"color:#204060\">{}</h2><p>{}</p>\
<p><a href=\"https://example.com/\">an external link</a></p></body></html>",
email.subject,
email.body.replace('\n', "<br>")
);
- self.webview.load_html(&html);
+ let logo = br##"<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><circle cx="24" cy="24" r="22" fill="#00a040"/></svg>"##;
+ let mail = HtmlMail {
+ html,
+ inline: vec![(
+ "demo-logo@cce-mail".to_string(),
+ "image/svg+xml".to_string(),
+ logo.to_vec(),
+ )],
+ };
+ self.show_html_mail(&mail);
self.html_loaded = Some(id);
return;
}
@@ -3088,6 +3217,15 @@ impl ClearEmailApp {
#[cfg(not(feature = "wpe"))]
fn request_html(&mut self, _id: usize) {}
+ /// Hand a message to the webview: inline images first (the `cid:`
+ /// handler must be able to answer the page's very first request), then
+ /// the HTML.
+ #[cfg(feature = "wpe")]
+ fn show_html_mail(&mut self, mail: &HtmlMail) {
+ self.webview.set_inline_parts(mail.inline.clone());
+ self.webview.load_html(&mail.html);
+ }
+
/// Drop the HTML view state (selection cleared, folder or account
/// switched, message deleted). The webview and its processes stay.
#[cfg(feature = "wpe")]
@@ -4122,13 +4260,13 @@ impl Application for ClearEmailApp {
if self.html_pending == Some(id) {
self.html_pending = None;
}
- if let Some(html) = html {
+ if let Some(mail) = html {
if self.html_cache.len() >= HTML_CACHE_CAP {
self.html_cache.clear();
}
- self.html_cache.insert(id, html.clone());
+ self.html_cache.insert(id, mail.clone());
if self.selected_email_id == Some(id) {
- self.webview.load_html(&html);
+ self.show_html_mail(&mail);
self.html_loaded = Some(id);
*needs_rebuild = true;
self.needs_rebuild = true;
@@ -5911,6 +6049,63 @@ mod tests {
assert_eq!(spec.charset.as_deref(), Some("ISO-8859-1"));
}
+ #[cfg(feature = "wpe")]
+ fn cid_part<'a>(ty: &'a str, subtype: &'a str, cid: &'a str) -> BodyStructure<'a> {
+ BodyStructure::Basic {
+ common: BodyContentCommon {
+ ty: ContentType { ty, subtype, params: None },
+ disposition: None,
+ language: None,
+ location: None,
+ },
+ other: BodyContentSinglePart {
+ id: Some(cid.into()),
+ md5: None,
+ description: None,
+ transfer_encoding: ContentEncoding::Base64,
+ octets: 0,
+ },
+ extension: None,
+ }
+ }
+
+ #[cfg(feature = "wpe")]
+ #[test]
+ fn inline_part_walk_finds_content_ids() {
+ // multipart/related( text/HTML, image/PNG cid ) inside mixed with a
+ // plain attachment — the classic inline-logo shape.
+ let bs = multipart(
+ "MIXED",
+ vec![
+ multipart(
+ "RELATED",
+ vec![
+ text_part("HTML", ContentEncoding::QuotedPrintable, None),
+ cid_part("IMAGE", "PNG", "<logo@corp>"),
+ ],
+ ),
+ basic_part("APPLICATION", "PDF"),
+ ],
+ );
+ let parts = find_inline_parts(&bs);
+ assert_eq!(
+ parts,
+ vec![InlinePartSpec {
+ cid: "logo@corp".to_string(), // brackets stripped
+ section: vec![1, 2],
+ encoding: "base64".to_string(),
+ mime: "image/png".to_string(),
+ }]
+ );
+ // A structure with no Content-IDs yields nothing — basic_part and
+ // text_part both carry id: None.
+ assert!(find_inline_parts(&multipart(
+ "MIXED",
+ vec![text_part("PLAIN", ContentEncoding::SevenBit, None)]
+ ))
+ .is_empty());
+ }
+
#[cfg(feature = "wpe")]
#[test]
fn html_part_walk_toplevel_and_absent() {
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index 56a0bc9..51822f1 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -10,10 +10,14 @@
//! * **The network session is ephemeral** — no cookies or cache ever touch
//! disk.
//! * **All remote loads are blocked by default** by a compiled WebKit content
-//! filter (`data:` stays allowed for inline images). Tracking pixels never
-//! fire. [`MailWebView::set_images_allowed`] lifts the filter for the
-//! current message only — an explicit per-message choice, reset on the
-//! next [`MailWebView::load_html`].
+//! filter (`data:` and `cid:` stay allowed — a message's own bytes carry
+//! no tracking). Tracking pixels never fire.
+//! [`MailWebView::set_images_allowed`] lifts the filter for the current
+//! message only — an explicit per-message choice, reset on the next
+//! [`MailWebView::load_html`].
+//! * **`cid:` inline attachments render natively**: a registered URI scheme
+//! handler serves them from the per-message store filled by
+//! [`MailWebView::set_inline_parts`].
//! * **Navigation never happens in-pane.** A link click is intercepted by
//! `decide-policy` and handed back through [`MailWebView::take_link_click`]
//! for the app to open externally; form submissions are dropped.
@@ -23,6 +27,7 @@
//! first [`MailWebView::load_html`], so a text-only session pays nothing.
use std::cell::{Cell, RefCell};
+use std::collections::HashMap;
use std::ffi::{c_char, c_void, CString};
use std::rc::Rc;
@@ -51,15 +56,23 @@ struct Pending {
frame: Option<(Vec<u8>, u32, u32)>,
}
-/// The WebKit content filter source: block every URL except `data:`, so a
-/// message renders from its own bytes alone. Compiled once (WebKit caches
-/// the compiled form in the store directory) and attached to the UCM
-/// whenever remote content is disallowed.
+/// The WebKit content filter source: block every URL except `data:` and
+/// `cid:`, so a message renders from its own bytes alone — inline
+/// attachments carry no tracking, which is why they pass while every
+/// remote load waits on the Load Images chip. Compiled once (WebKit caches
+/// the compiled form in the store directory, keyed by [`FILTER_ID`]) and
+/// attached to the UCM whenever remote content is disallowed.
const BLOCK_REMOTE_FILTER: &str = r#"[
{"trigger": {"url-filter": ".*"}, "action": {"type": "block"}},
- {"trigger": {"url-filter": "^data:"}, "action": {"type": "ignore-previous-rules"}}
+ {"trigger": {"url-filter": "^data:"}, "action": {"type": "ignore-previous-rules"}},
+ {"trigger": {"url-filter": "^cid:"}, "action": {"type": "ignore-previous-rules"}}
]"#;
+/// Bumped whenever [`BLOCK_REMOTE_FILTER`] changes: the store caches the
+/// compiled filter under this name, and a new name is cheaper to reason
+/// about than trusting it to notice changed source.
+const FILTER_ID: &str = "block-remote-v2";
+
pub struct MailWebView {
display: *mut WPEDisplay,
toplevel: *mut WPEToplevel,
@@ -80,6 +93,9 @@ pub struct MailWebView {
/// The message currently loaded, kept so lifting the image block can
/// re-render the same content.
html: Option<CString>,
+ /// The current message's inline attachments, served by the `cid:`
+ /// scheme handler: content-id → (mime type, decoded bytes).
+ inline: Rc<RefCell<HashMap<String, (String, Vec<u8>)>>>,
images_allowed: bool,
/// Last uploaded frame in the image registry: (id, w px, h px).
image: Option<(u32, u32, u32)>,
@@ -130,6 +146,21 @@ impl MailWebView {
let filter = compile_block_filter();
+ // The `cid:` scheme, served straight out of the inline store —
+ // the same registration cce-browser uses for its `cce:` pages.
+ // Process-wide and registered once, like the frame sink.
+ let inline: Rc<RefCell<HashMap<String, (String, Vec<u8>)>>> =
+ Rc::new(RefCell::new(HashMap::new()));
+ let ctx = webkit_web_context_get_default();
+ let scheme = cstr("cid");
+ webkit_web_context_register_uri_scheme(
+ ctx,
+ scheme.as_ptr(),
+ Some(on_cid_request),
+ Rc::into_raw(inline.clone()) as gpointer,
+ None,
+ );
+
Self {
display,
toplevel,
@@ -145,6 +176,7 @@ impl MailWebView {
.ok(),
links: Rc::new(RefCell::new(Vec::new())),
html: None,
+ inline,
images_allowed: false,
image: None,
last_frame: None,
@@ -175,11 +207,15 @@ impl MailWebView {
std::ptr::null::<c_char>(),
) as *mut WebKitWebView;
- // The lockdown. JavaScript stays off for the life of the view;
- // images follow `images_allowed`.
+ // The lockdown. JavaScript stays off for the life of the view.
+ // With a compiled filter the image setting stays ON — the filter
+ // is what gates remote loads, and it lets cid:/data: through so
+ // inline attachments always render. Only when the filter failed
+ // to compile does auto-load-images carry the block alone, at the
+ // cost of inline images too (privacy over completeness).
let settings = webkit_web_view_get_settings(wv);
webkit_settings_set_enable_javascript(settings, 0);
- webkit_settings_set_auto_load_images(settings, self.images_allowed as gboolean);
+ webkit_settings_set_auto_load_images(settings, self.images_on() as gboolean);
self.apply_filter_policy();
// Link clicks leave through the app, never navigate in-pane.
@@ -247,10 +283,22 @@ impl MailWebView {
self.reload_current();
}
+ /// Install the message's inline attachments for the `cid:` handler,
+ /// replacing the previous message's. Call BEFORE `load_html`, or the
+ /// page's image requests race the store swap.
+ pub fn set_inline_parts(&mut self, parts: Vec<(String, String, Vec<u8>)>) {
+ let mut store = self.inline.borrow_mut();
+ store.clear();
+ for (cid, mime, bytes) in parts {
+ store.insert(cid, (mime, bytes));
+ }
+ }
+
/// Drop the shown message (selection cleared / folder switched). The
/// view and its processes stay for the next message.
pub fn clear(&mut self) {
self.html = None;
+ self.inline.borrow_mut().clear();
self.links.borrow_mut().clear();
self.pending.borrow_mut().frame = None;
if let Some((id, ..)) = self.image.take() {
@@ -279,12 +327,19 @@ impl MailWebView {
self.images_allowed
}
+ /// Whether WebKit's own image loading is on — see `ensure_view` for why
+ /// this is not simply `images_allowed`.
+ fn images_on(&self) -> bool {
+ self.images_allowed || !self.filter.is_null()
+ }
+
fn reload_current(&mut self) {
let Some(html) = self.html.clone() else { return };
+ let images_on = self.images_on();
let (wv, _) = self.ensure_view();
unsafe {
let settings = webkit_web_view_get_settings(wv);
- webkit_settings_set_auto_load_images(settings, self.images_allowed as gboolean);
+ webkit_settings_set_auto_load_images(settings, images_on as gboolean);
webkit_web_view_load_html(wv, html.as_ptr(), std::ptr::null());
}
// The old message's frame must not linger under the new one — the
@@ -565,7 +620,7 @@ unsafe fn compile_block_filter() -> *mut WebKitUserContentFilter {
let cdir = cstr(&dir.to_string_lossy());
let store = webkit_user_content_filter_store_new(cdir.as_ptr());
- let id = cstr("block-remote");
+ let id = cstr(FILTER_ID);
let bytes = g_bytes_new(
BLOCK_REMOTE_FILTER.as_ptr() as *const c_void,
BLOCK_REMOTE_FILTER.len() as u64,
@@ -605,6 +660,56 @@ unsafe extern "C" fn drop_links_ref(data: gpointer, _c: *mut GClosure) {
drop(Rc::from_raw(data as *const RefCell<Vec<String>>));
}
+/// Minimal %XX decoding for the `cid:` URI path — Content-IDs are almost
+/// always plain, but `@` does arrive as `%40` from some composers.
+fn percent_decode_bytes(s: &str) -> String {
+ let bytes = s.as_bytes();
+ let mut out = Vec::with_capacity(bytes.len());
+ let mut i = 0;
+ while i < bytes.len() {
+ if bytes[i] == b'%' {
+ if let (Some(h), Some(l)) = (
+ bytes.get(i + 1).and_then(|b| (*b as char).to_digit(16)),
+ bytes.get(i + 2).and_then(|b| (*b as char).to_digit(16)),
+ ) {
+ out.push((h * 16 + l) as u8);
+ i += 3;
+ continue;
+ }
+ }
+ out.push(bytes[i]);
+ i += 1;
+ }
+ String::from_utf8_lossy(&out).into_owned()
+}
+
+/// Serves the page's `cid:` image requests from the inline store. Runs on
+/// the main thread (the cce-browser `cce:` handler's contract). A cid the
+/// message structure did not carry answers with an error — a broken-image
+/// glyph, never a network fetch.
+unsafe extern "C" fn on_cid_request(request: *mut WebKitURISchemeRequest, data: gpointer) {
+ let store = &*(data as *const RefCell<HashMap<String, (String, Vec<u8>)>>);
+ let uri = from_cstr(webkit_uri_scheme_request_get_uri(request)).unwrap_or_default();
+ let cid = percent_decode_bytes(uri.strip_prefix("cid:").unwrap_or(""));
+ match store.borrow().get(&cid) {
+ Some((mime, bytes)) => {
+ // g_bytes_new copies; the stream owns that copy outright.
+ let gb = g_bytes_new(bytes.as_ptr() as *const c_void, bytes.len() as u64);
+ let stream = g_memory_input_stream_new_from_bytes(gb);
+ let ctype = cstr(mime);
+ webkit_uri_scheme_request_finish(request, stream, bytes.len() as i64, ctype.as_ptr());
+ g_bytes_unref(gb);
+ g_object_unref(stream as *mut _);
+ }
+ None => {
+ let msg = cstr(&format!("no inline part for cid:{cid}"));
+ let err = g_error_new_literal(1, 0, msg.as_ptr());
+ webkit_uri_scheme_request_finish_error(request, err);
+ g_error_free(err);
+ }
+ }
+}
+
/// Every navigation decision. The initial `load_html` arrives as type OTHER
/// and passes; a clicked link is stashed for external opening; everything
/// else (forms, window.open targets) is refused outright.