git.lucas.co / cce-grid
desktop grid client
git clone https://git.lucas.co/cce-grid.git

src/items.rs (23.9K)

  1 // Desktop items: images pinned to the world canvas.
  2 //
  3 // A drop on the desktop background lands here (the compositor routes drags
  4 // over the background onto the grid client — see its `Scene::at`, which hit-tests
  5 // the grid layer through its input region since cce-compositor@b82a0ee).
  6 // Each item is saved to the desktop folder AND recorded in a sidecar with the
  7 // virtual-canvas position it was dropped at, so it reappears in the same world
  8 // spot next session. Nothing here touches the GPU: the caller uploads the
  9 // decoded pixels, because that must happen on the main loop.
 10 //
 11 // Fetching shells out to curl rather than linking an HTTP stack. This process
 12 // is a background renderer that otherwise needs no network at all, and a
 13 // dropped web image is a once-in-a-while user action where process startup is
 14 // far below the notice threshold — an async runtime and a TLS stack would be
 15 // the largest thing in the binary, for that.
 16 
 17 use std::path::{Path, PathBuf};
 18 
 19 /// What a desktop item is — one JSON Canvas node (see `board.rs`).
 20 #[derive(Debug, Clone, PartialEq)]
 21 pub enum Kind {
 22     /// An image file, drawn as its pixels.
 23     Image(PathBuf),
 24     /// A Markdown note, drawn as a card of its content (MarkdownView).
 25     Note(PathBuf),
 26     /// Any other file: a card with its name.
 27     File(PathBuf),
 28     /// A text card: Markdown kept in the canvas itself.
 29     Text(String),
 30     Link(String),
 31     /// A labelled frame behind other items; not interactive.
 32     Group(String),
 33     /// A node type this client does not know, kept so a save returns it.
 34     Other(String),
 35 }
 36 
 37 /// A `file` node's kind, by extension.
 38 pub fn kind_for_file(path: PathBuf) -> Kind {
 39     let ext = path.extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default();
 40     match ext.as_str() {
 41         "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => Kind::Image(path),
 42         "md" => Kind::Note(path),
 43         _ => Kind::File(path),
 44     }
 45 }
 46 
 47 /// One pinned item, in virtual-surface coordinates (the same space windows
 48 /// and grid squares live in), so items pan and zoom with the desktop.
 49 #[derive(Debug, Clone, PartialEq)]
 50 pub struct DesktopItem {
 51     /// The canvas node id: the identity across sessions and in edges.
 52     pub node: String,
 53     pub kind: Kind,
 54     /// This process's name for the item in its reports to the compositor
 55     /// (`grid-items`), which addresses a group move's `move` lines by it.
 56     /// Assigned when the item enters the list (`GridApp::assign_id`).
 57     pub id: u64,
 58     pub x: f64,
 59     pub y: f64,
 60     pub w: f64,
 61     pub h: f64,
 62     /// JSON Canvas colour (`"1"`–`"6"` or `#rrggbb`), kept as written.
 63     pub color: Option<String>,
 64 }
 65 
 66 impl DesktopItem {
 67     pub fn new(node: String, kind: Kind, x: f64, y: f64, w: f64, h: f64) -> DesktopItem {
 68         DesktopItem { node, kind, id: 0, x, y, w, h, color: None }
 69     }
 70 
 71     pub fn is_image(&self) -> bool {
 72         matches!(self.kind, Kind::Image(_))
 73     }
 74 
 75     /// Groups and unknown nodes take no input and are not reported to the
 76     /// compositor: a group spans a region the background must keep.
 77     pub fn interactive(&self) -> bool {
 78         !matches!(self.kind, Kind::Group(_) | Kind::Other(_))
 79     }
 80 
 81     /// A short name for menus and logs.
 82     pub fn name(&self) -> String {
 83         match &self.kind {
 84             Kind::Image(p) | Kind::Note(p) | Kind::File(p) => {
 85                 p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
 86             }
 87             Kind::Text(t) => {
 88                 let first = t.lines().find(|l| !l.trim().is_empty()).unwrap_or("Text").trim();
 89                 first.chars().take(40).collect()
 90             }
 91             Kind::Link(u) => u.clone(),
 92             Kind::Group(l) => l.clone(),
 93             Kind::Other(t) => t.clone(),
 94         }
 95     }
 96 }
 97 
 98 /// The desktop folder: `$XDG_DESKTOP_DIR` when the user-dirs config exports
 99 /// one, else `~/Desktop`.
100 pub fn desktop_dir() -> PathBuf {
101     if let Ok(dir) = std::env::var("XDG_DESKTOP_DIR") {
102         if !dir.is_empty() {
103             return PathBuf::from(dir);
104         }
105     }
106     PathBuf::from(std::env::var("HOME").unwrap_or_default()).join("Desktop")
107 }
108 
109 /// The URI (or raw image bytes) a drop payload actually carries.
110 pub enum Payload {
111     Uri(String),
112     Bytes(Vec<u8>),
113 }
114 
115 /// The `src` of the first `<img>` in a fragment of HTML. Browsers offer
116 /// `text/html` alongside the URL flavours, and it is the only one that names
117 /// the IMAGE when the image is wrapped in a link — which is exactly how a
118 /// Google Images thumbnail is marked up, so `text/uri-list` there is the
119 /// result page, not the picture.
120 fn img_src(html: &str) -> Option<String> {
121     let lower = html.to_ascii_lowercase();
122     let mut from = 0;
123     while let Some(tag) = lower[from..].find("<img") {
124         let tag = from + tag;
125         let rest = &lower[tag..];
126         let end = rest.find('>').map(|e| tag + e).unwrap_or(lower.len());
127         if let Some(src) = lower[tag..end].find("src") {
128             let after = tag + src + 3;
129             let seg = &html[after..end.min(html.len())];
130             // src = "..." | '...' | bare
131             let seg = seg.trim_start().strip_prefix('=')?.trim_start();
132             let value = match seg.chars().next() {
133                 Some('"') => seg[1..].split('"').next(),
134                 Some('\'') => seg[1..].split('\'').next(),
135                 _ => seg.split_whitespace().next(),
136             };
137             if let Some(v) = value {
138                 if !v.is_empty() {
139                     return Some(v.to_string());
140                 }
141             }
142         }
143         from = end.max(tag + 4);
144     }
145     None
146 }
147 
148 /// Interpret a drop by mime type. Browsers hand over a *link* for an image on
149 /// a page — the pixels only travel directly when the source made them itself
150 /// (a canvas, an image editor), which is why both shapes are handled.
151 pub fn parse_payload(mime: &str, data: &[u8]) -> Option<Payload> {
152     if mime.starts_with("image/") {
153         return Some(Payload::Bytes(data.to_vec()));
154     }
155     if mime.starts_with("text/html") {
156         let html = String::from_utf8_lossy(data);
157         return img_src(&html).map(Payload::Uri);
158     }
159     let text = if mime == "text/x-moz-url" {
160         // Firefox's own flavour is UTF-16LE, "url\ntitle".
161         let units: Vec<u16> = data
162             .chunks_exact(2)
163             .map(|p| u16::from_le_bytes([p[0], p[1]]))
164             .collect();
165         String::from_utf16_lossy(&units)
166     } else {
167         String::from_utf8_lossy(data).into_owned()
168     };
169     // text/uri-list is line-based with '#' comments; the other text flavours
170     // are a bare URL. Taking the first usable line covers both.
171     let uri = text
172         .lines()
173         .map(|l| l.trim())
174         .find(|l| !l.is_empty() && !l.starts_with('#'))?;
175     Some(Payload::Uri(uri.to_string()))
176 }
177 
178 /// Percent-decode enough of a `file://` URI to get a real path back.
179 fn percent_decode(s: &str) -> String {
180     let bytes = s.as_bytes();
181     let mut out = Vec::with_capacity(bytes.len());
182     let mut i = 0;
183     while i < bytes.len() {
184         if bytes[i] == b'%' && i + 2 < bytes.len() {
185             if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
186                 out.push(b);
187                 i += 3;
188                 continue;
189             }
190         }
191         out.push(bytes[i]);
192         i += 1;
193     }
194     String::from_utf8_lossy(&out).into_owned()
195 }
196 
197 /// A `file://` URI's path part, percent-decoded.
198 pub fn percent_decode_path(s: &str) -> PathBuf {
199     PathBuf::from(percent_decode(s))
200 }
201 
202 /// A filename for the saved copy: the URI's last path segment when it looks
203 /// like a filename, else a generic name. Query strings and fragments are
204 /// stripped — plenty of image URLs end in `?w=800`.
205 fn file_name_for(uri: &str, fallback_ext: &str) -> String {
206     let trimmed = uri.split(['?', '#']).next().unwrap_or(uri);
207     let last = trimmed.rsplit('/').next().unwrap_or("");
208     let last = percent_decode(last);
209     let looks_named = !last.is_empty() && last.contains('.') && last.len() <= 128;
210     if looks_named {
211         last
212     } else {
213         format!("dropped-image.{fallback_ext}")
214     }
215 }
216 
217 /// A path in `dir` that does not exist yet, suffixing `-2`, `-3`, … A drop
218 /// must never overwrite a file the user already has.
219 fn unique_path(dir: &Path, name: &str) -> PathBuf {
220     let candidate = dir.join(name);
221     if !candidate.exists() {
222         return candidate;
223     }
224     let (stem, ext) = match name.rsplit_once('.') {
225         Some((s, e)) => (s.to_string(), format!(".{e}")),
226         None => (name.to_string(), String::new()),
227     };
228     for n in 2..10_000 {
229         let candidate = dir.join(format!("{stem}-{n}{ext}"));
230         if !candidate.exists() {
231             return candidate;
232         }
233     }
234     dir.join(name)
235 }
236 
237 /// Sniff a container from magic bytes — the extension in a URL is a guess and
238 /// content-type is not carried through a drop.
239 fn extension_for(bytes: &[u8]) -> &'static str {
240     if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
241         "png"
242     } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
243         "jpg"
244     } else {
245         "bin"
246     }
247 }
248 
249 /// Fetch the drop's bytes: a local file is read, anything else goes through
250 /// curl. Returns the bytes and the name to save them under.
251 pub fn fetch(payload: Payload) -> std::io::Result<(Vec<u8>, String)> {
252     match payload {
253         Payload::Bytes(bytes) => {
254             let ext = extension_for(&bytes);
255             Ok((bytes, format!("dropped-image.{ext}")))
256         }
257         // Inline data, the way Google Images serves its thumbnails. No fetch
258         // to do — the bytes are in the URI.
259         Payload::Uri(uri) if uri.starts_with("data:") => {
260             let rest = &uri["data:".len()..];
261             let (meta, body) = rest.split_once(',').ok_or_else(|| {
262                 std::io::Error::new(std::io::ErrorKind::InvalidData, "malformed data: URI")
263             })?;
264             let bytes = if meta.ends_with(";base64") {
265                 decode_base64(body).ok_or_else(|| {
266                     std::io::Error::new(std::io::ErrorKind::InvalidData, "bad base64 in data: URI")
267                 })?
268             } else {
269                 percent_decode(body).into_bytes()
270             };
271             let ext = extension_for(&bytes);
272             Ok((bytes, format!("dropped-image.{ext}")))
273         }
274         Payload::Uri(uri) if uri.starts_with("file://") => {
275             let path = percent_decode(uri.trim_start_matches("file://"));
276             let bytes = std::fs::read(&path)?;
277             let name = Path::new(&path)
278                 .file_name()
279                 .map(|n| n.to_string_lossy().into_owned())
280                 .unwrap_or_else(|| file_name_for(&uri, extension_for(&bytes)));
281             Ok((bytes, name))
282         }
283         Payload::Uri(uri) => {
284             if !uri.starts_with("http://") && !uri.starts_with("https://") {
285                 return Err(std::io::Error::new(
286                     std::io::ErrorKind::InvalidInput,
287                     format!("unsupported URI scheme: {uri}"),
288                 ));
289             }
290             let out = std::process::Command::new("curl")
291                 .args([
292                     "--location",
293                     "--fail",
294                     "--silent",
295                     "--show-error",
296                     // A drop should not be able to hang a renderer thread
297                     // forever on a dead host.
298                     "--max-time",
299                     "30",
300                     "--max-filesize",
301                     "67108864",
302                     &uri,
303                 ])
304                 .output()?;
305             if !out.status.success() {
306                 return Err(std::io::Error::other(format!(
307                     "curl failed for {uri}: {}",
308                     String::from_utf8_lossy(&out.stderr).trim()
309                 )));
310             }
311             let name = file_name_for(&uri, extension_for(&out.stdout));
312             Ok((out.stdout, name))
313         }
314     }
315 }
316 
317 /// Standard base64 (RFC 4648) to bytes, tolerating whitespace and padding.
318 /// Hand-rolled rather than pulled in: it is twenty lines, and the only user
319 /// is `data:` URIs.
320 fn decode_base64(s: &str) -> Option<Vec<u8>> {
321     fn val(c: u8) -> Option<u32> {
322         match c {
323             b'A'..=b'Z' => Some((c - b'A') as u32),
324             b'a'..=b'z' => Some((c - b'a') as u32 + 26),
325             b'0'..=b'9' => Some((c - b'0') as u32 + 52),
326             b'+' => Some(62),
327             b'/' => Some(63),
328             _ => None,
329         }
330     }
331     let mut out = Vec::with_capacity(s.len() * 3 / 4);
332     let mut acc: u32 = 0;
333     let mut bits = 0;
334     for c in s.bytes() {
335         if c.is_ascii_whitespace() || c == b'=' {
336             continue;
337         }
338         acc = (acc << 6) | val(c)?;
339         bits += 6;
340         if bits >= 8 {
341             bits -= 8;
342             out.push((acc >> bits) as u8);
343         }
344     }
345     Some(out)
346 }
347 
348 /// Where a dropped image is kept. With the board in a vault: inside it, in
349 /// the folder Obsidian puts new attachments for `board_file` in
350 /// (`cce_vault::attachments`), so the picture syncs with the board and
351 /// Obsidian shows the node instead of a missing file. Without a vault: the
352 /// desktop folder, as before.
353 pub fn save_image(bytes: &[u8], name: &str, vault: Option<&Path>, board_file: &str) -> std::io::Result<PathBuf> {
354     let Some(vault) = vault else { return save_to_desktop(bytes, name) };
355     let dir = vault.join(cce_vault::attachments::folder(vault, board_file));
356     std::fs::create_dir_all(&dir)?;
357     let path = cce_vault::attachments::unique_path(&dir, name);
358     std::fs::write(&path, bytes)?;
359     Ok(path)
360 }
361 
362 /// Copy an image pinned from outside the vault into it (see [`save_image`]),
363 /// leaving the original where it was. The new path, or `None` when it is
364 /// already inside, or cannot be read.
365 pub fn adopt_into_vault(path: &Path, vault: &Path, board_file: &str) -> Option<PathBuf> {
366     if path.starts_with(vault) {
367         return None;
368     }
369     let bytes = std::fs::read(path).ok()?;
370     let name = path.file_name()?.to_string_lossy().into_owned();
371     save_image(&bytes, &name, Some(vault), board_file)
372         .map_err(|e| log::warn!("[board] copying {} into the vault: {e}", path.display()))
373         .ok()
374 }
375 
376 /// Save bytes into the desktop folder under a non-colliding name.
377 pub fn save_to_desktop(bytes: &[u8], name: &str) -> std::io::Result<PathBuf> {
378     let dir = desktop_dir();
379     std::fs::create_dir_all(&dir)?;
380     let path = unique_path(&dir, name);
381     std::fs::write(&path, bytes)?;
382     Ok(path)
383 }
384 
385 /// Resolve a cce binary that is installed beside this one.
386 ///
387 /// This process runs as a systemd user service, whose PATH is
388 /// `/usr/local/bin:/usr/bin` — `~/.local/bin`, where every cce binary is
389 /// installed, is NOT on it. Spawning one by bare name therefore fails with
390 /// ENOENT under systemd while working perfectly from a shell or when the
391 /// compositor spawns it, which is exactly how the context menu shipped
392 /// broken: it worked in every test and never once on the real desktop.
393 fn de_bin(name: &str) -> std::path::PathBuf {
394     if let Ok(exe) = std::env::current_exe() {
395         if let Some(dir) = exe.parent() {
396             let beside = dir.join(name);
397             if beside.exists() {
398                 return beside;
399             }
400         }
401     }
402     // Fall back to PATH: a dev build run straight out of target/ has no cce
403     // binaries beside it, but does have them on PATH.
404     std::path::PathBuf::from(name)
405 }
406 
407 /// Show a context menu titled `title` with `entries` (action id, label) and
408 /// return the chosen action id, if any. Runs on a worker thread: it blocks
409 /// until the menu closes.
410 ///
411 /// The menu is a `cce-cloud --json` popup, the same mechanism the desktop and
412 /// app context menus use, so it looks and behaves like every other menu in the
413 /// DE rather than something this client drew for itself. The pointer's screen
414 /// position has to be asked for — a client knows where its own surface was
415 /// touched, never where that is on the screen.
416 pub fn item_menu(title: &str, entries: &[(&str, &str)]) -> Option<String> {
417     use std::io::Write;
418 
419     let loc = std::process::Command::new(de_bin("ccectl")).arg("pointer-location").output().ok()?;
420     let loc = String::from_utf8_lossy(&loc.stdout);
421     let coord = |key: &str| -> Option<i32> {
422         loc.split_whitespace()
423             .find_map(|t| t.strip_prefix(key))
424             .and_then(|v| v.trim().parse::<f64>().ok())
425             .map(|v| v.round() as i32)
426     };
427     let (x, y) = (coord("x=")?, coord("y=")?);
428 
429     let widgets: Vec<serde_json::Value> = entries
430         .iter()
431         .map(|(id, label)| serde_json::json!({"type": "button", "text": label, "id": id}))
432         .collect();
433     let layout = serde_json::json!({"pages": [{"title": title, "justify": "left", "widgets": widgets}]}).to_string();
434 
435     let mut child = std::process::Command::new(de_bin("cce-cloud"))
436         .args(["--json", "-x", &x.to_string(), "-y", &y.to_string()])
437         .stdin(std::process::Stdio::piped())
438         .stdout(std::process::Stdio::piped())
439         .stderr(std::process::Stdio::null())
440         .spawn()
441         .ok()?;
442     child.stdin.take()?.write_all(layout.as_bytes()).ok()?;
443     let out = child.wait_with_output().ok()?;
444     let reply: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
445     reply.get("button")?.as_str().map(|s| s.to_string())
446 }
447 
448 /// Hand a path or URL to its default app (`xdg-open`), detached.
449 pub fn open_externally(target: &str) {
450     let _ = std::process::Command::new("xdg-open")
451         .arg(target)
452         .stdout(std::process::Stdio::null())
453         .stderr(std::process::Stdio::null())
454         .spawn();
455 }
456 
457 /// Show a note in cce-notes: hand it to the running instance over its
458 /// socket, or start one.
459 pub fn open_in_notes(path: &Path) {
460     use std::io::{BufRead, BufReader, Write};
461     if let Ok(mut s) = std::os::unix::net::UnixStream::connect(cce_ui::ipc::socket_path("cce-notes")) {
462         if s.write_all(format!("open {}\n", path.display()).as_bytes()).is_ok() {
463             let mut reply = String::new();
464             let _ = BufReader::new(s).read_line(&mut reply);
465             return;
466         }
467     }
468     let _ = std::process::Command::new(de_bin("cce-notes")).arg("open").arg(path).spawn();
469 }
470 
471 /// Tell the user a drop failed, and why. A drop that silently does nothing
472 /// is indistinguishable from one the desktop never received, so every failure
473 /// path goes through here rather than only into the log.
474 pub fn report_failure(reason: &str) {
475     log::warn!("[items] drop failed: {reason}");
476     let _ = std::process::Command::new(de_bin("ccectl"))
477         .args(["notify", "Image not added to the desktop", reason])
478         .stdout(std::process::Stdio::null())
479         .stderr(std::process::Stdio::null())
480         .status();
481 }
482 
483 /// Decode to straight RGBA8 for `cce_ui::vk::upload_rgba`.
484 pub fn decode_rgba(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
485     let img = image::load_from_memory(bytes).ok()?;
486     let rgba = img.to_rgba8();
487     let (w, h) = (rgba.width(), rgba.height());
488     Some((rgba.into_raw(), w, h))
489 }
490 
491 #[cfg(test)]
492 mod tests {
493     use super::*;
494 
495     #[test]
496     fn dropped_images_land_in_the_vault_attachment_folder() {
497         let vault = tempfile::tempdir().unwrap();
498         let v = vault.path();
499         let p = save_image(b"png", "pic.png", Some(v), "Desktop.canvas").unwrap();
500         assert_eq!(p, v.join("pic.png"));
501         // A clash is numbered the way Obsidian numbers it.
502         assert_eq!(save_image(b"png", "pic.png", Some(v), "Desktop.canvas").unwrap(), v.join("pic 1.png"));
503         // Obsidian's attachment folder setting is followed.
504         std::fs::create_dir_all(v.join(".obsidian")).unwrap();
505         std::fs::write(v.join(".obsidian/app.json"), r#"{"attachmentFolderPath":"Attachments"}"#).unwrap();
506         assert_eq!(save_image(b"png", "pic.png", Some(v), "Desktop.canvas").unwrap(), v.join("Attachments/pic.png"));
507     }
508 
509     #[test]
510     fn outside_images_are_copied_in_and_inside_ones_left() {
511         let (vault, outside) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
512         let src = outside.path().join("shot.jpg");
513         std::fs::write(&src, b"jpg").unwrap();
514         let new = adopt_into_vault(&src, vault.path(), "Desktop.canvas").unwrap();
515         assert_eq!(new, vault.path().join("shot.jpg"));
516         assert_eq!(std::fs::read(&new).unwrap(), b"jpg");
517         assert!(src.exists(), "the original stays");
518         assert_eq!(adopt_into_vault(&new, vault.path(), "Desktop.canvas"), None);
519         assert_eq!(adopt_into_vault(&outside.path().join("gone.jpg"), vault.path(), "Desktop.canvas"), None);
520     }
521 
522     #[test]
523     fn uri_list_takes_the_first_real_line() {
524         let data = b"# comment\r\nhttps://example.com/cat.png\r\nhttps://other\r\n";
525         let Some(Payload::Uri(u)) = parse_payload("text/uri-list", data) else {
526             panic!("expected a URI")
527         };
528         assert_eq!(u, "https://example.com/cat.png");
529     }
530 
531     #[test]
532     fn moz_url_is_utf16() {
533         // "http://a/b.png\nTitle" in UTF-16LE, as Firefox sends it.
534         let s = "http://a/b.png\nTitle";
535         let data: Vec<u8> = s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
536         let Some(Payload::Uri(u)) = parse_payload("text/x-moz-url", &data) else {
537             panic!("expected a URI")
538         };
539         assert_eq!(u, "http://a/b.png");
540     }
541 
542     #[test]
543     fn image_mimes_carry_bytes_not_links() {
544         let Some(Payload::Bytes(b)) = parse_payload("image/png", &[1, 2, 3]) else {
545             panic!("expected bytes")
546         };
547         assert_eq!(b, vec![1, 2, 3]);
548     }
549 
550     #[test]
551     fn file_names_survive_query_strings_and_fall_back() {
552         assert_eq!(file_name_for("https://x.com/a/cat.png?w=800", "png"), "cat.png");
553         assert_eq!(file_name_for("https://x.com/a/photo%20one.jpg", "jpg"), "photo one.jpg");
554         // No filename in the path at all.
555         assert_eq!(file_name_for("https://x.com/render?id=9", "png"), "dropped-image.png");
556     }
557 
558     #[test]
559     fn html_flavour_names_the_image_not_the_link() {
560         // A Google-Images-shaped fragment: the <img> is inside an <a>, so the
561         // link URL is useless and only the img src names the picture.
562         let html = br#"<a href="/imgres?q=cat"><img src="https://x.com/cat.png" alt="c"></a>"#;
563         let Some(Payload::Uri(u)) = parse_payload("text/html", html) else {
564             panic!("expected a URI")
565         };
566         assert_eq!(u, "https://x.com/cat.png");
567         // Single quotes and no quotes both parse.
568         let Some(Payload::Uri(u)) = parse_payload("text/html", b"<img src='/a.png'>") else {
569             panic!()
570         };
571         assert_eq!(u, "/a.png");
572         // Markup with no image at all is not a drop we can use.
573         assert!(parse_payload("text/html", b"<p>hello</p>").is_none());
574     }
575 
576     #[test]
577     fn data_uris_carry_their_own_bytes() {
578         // "PNG" magic, base64'd, as an inline thumbnail arrives.
579         let png = [0x89u8, b'P', b'N', b'G', 0x0d];
580         let b64 = {
581             const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
582             let mut o = String::new();
583             for c in png.chunks(3) {
584                 let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)];
585                 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
586                 for i in 0..4 {
587                     if i <= c.len() {
588                         o.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char);
589                     } else {
590                         o.push('=');
591                     }
592                 }
593             }
594             o
595         };
596         let uri = format!("data:image/png;base64,{b64}");
597         let (bytes, name) = fetch(Payload::Uri(uri)).expect("data: URI decodes");
598         assert_eq!(&bytes[..5], &png[..]);
599         assert_eq!(name, "dropped-image.png");
600     }
601 
602     #[test]
603     fn base64_roundtrips_known_vectors() {
604         assert_eq!(decode_base64("TWFu").unwrap(), b"Man".to_vec());
605         assert_eq!(decode_base64("TWE=").unwrap(), b"Ma".to_vec());
606         assert_eq!(decode_base64("TW E =\n").unwrap(), b"Ma".to_vec());
607     }
608 
609     #[test]
610     fn extension_is_sniffed_from_content() {
611         assert_eq!(extension_for(&[0x89, b'P', b'N', b'G', 0]), "png");
612         assert_eq!(extension_for(&[0xFF, 0xD8, 0xFF, 0]), "jpg");
613         assert_eq!(extension_for(b"not an image"), "bin");
614     }
615 }