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 }