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

src/items.rs (19K)

  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 /// One pinned image, in virtual-surface coordinates (the same space windows
 20 /// and grid squares live in), so items pan and zoom with the desktop.
 21 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 22 pub struct DesktopItem {
 23     /// Where the image was saved — the sidecar stores a path, not pixels.
 24     pub path: PathBuf,
 25     pub x: f64,
 26     pub y: f64,
 27     pub w: f64,
 28     pub h: f64,
 29 }
 30 
 31 /// `$XDG_DATA_HOME/cce/desktop-items.json`.
 32 pub fn sidecar_path() -> PathBuf {
 33     cce_ui::config::data_home().join("cce").join("desktop-items.json")
 34 }
 35 
 36 /// The desktop folder: `$XDG_DESKTOP_DIR` when the user-dirs config exports
 37 /// one, else `~/Desktop`.
 38 pub fn desktop_dir() -> PathBuf {
 39     if let Ok(dir) = std::env::var("XDG_DESKTOP_DIR") {
 40         if !dir.is_empty() {
 41             return PathBuf::from(dir);
 42         }
 43     }
 44     PathBuf::from(std::env::var("HOME").unwrap_or_default()).join("Desktop")
 45 }
 46 
 47 pub fn load() -> Vec<DesktopItem> {
 48     let path = sidecar_path();
 49     let Ok(text) = std::fs::read_to_string(&path) else { return Vec::new() };
 50     match serde_json::from_str::<Vec<DesktopItem>>(&text) {
 51         Ok(items) => items,
 52         Err(e) => {
 53             // A corrupt sidecar must not cost the user their other items on
 54             // the next write, so refuse to start from an empty list: keep the
 55             // file untouched and run with nothing until it is fixed.
 56             log::error!("[items] {} is unreadable ({e}); not loading or rewriting it", path.display());
 57             Vec::new()
 58         }
 59     }
 60 }
 61 
 62 pub fn save(items: &[DesktopItem]) {
 63     let path = sidecar_path();
 64     if let Some(parent) = path.parent() {
 65         let _ = std::fs::create_dir_all(parent);
 66     }
 67     let Ok(text) = serde_json::to_string_pretty(items) else { return };
 68     // Write-then-rename: a crash mid-write would otherwise leave a truncated
 69     // sidecar, which is exactly the corrupt-file case above.
 70     let tmp = path.with_extension("json.tmp");
 71     if std::fs::write(&tmp, text).is_ok() {
 72         let _ = std::fs::rename(&tmp, &path);
 73     }
 74 }
 75 
 76 /// The URI (or raw image bytes) a drop payload actually carries.
 77 pub enum Payload {
 78     Uri(String),
 79     Bytes(Vec<u8>),
 80 }
 81 
 82 /// The `src` of the first `<img>` in a fragment of HTML. Browsers offer
 83 /// `text/html` alongside the URL flavours, and it is the only one that names
 84 /// the IMAGE when the image is wrapped in a link — which is exactly how a
 85 /// Google Images thumbnail is marked up, so `text/uri-list` there is the
 86 /// result page, not the picture.
 87 fn img_src(html: &str) -> Option<String> {
 88     let lower = html.to_ascii_lowercase();
 89     let mut from = 0;
 90     while let Some(tag) = lower[from..].find("<img") {
 91         let tag = from + tag;
 92         let rest = &lower[tag..];
 93         let end = rest.find('>').map(|e| tag + e).unwrap_or(lower.len());
 94         if let Some(src) = lower[tag..end].find("src") {
 95             let after = tag + src + 3;
 96             let seg = &html[after..end.min(html.len())];
 97             // src = "..." | '...' | bare
 98             let seg = seg.trim_start().strip_prefix('=')?.trim_start();
 99             let value = match seg.chars().next() {
100                 Some('"') => seg[1..].split('"').next(),
101                 Some('\'') => seg[1..].split('\'').next(),
102                 _ => seg.split_whitespace().next(),
103             };
104             if let Some(v) = value {
105                 if !v.is_empty() {
106                     return Some(v.to_string());
107                 }
108             }
109         }
110         from = end.max(tag + 4);
111     }
112     None
113 }
114 
115 /// Interpret a drop by mime type. Browsers hand over a *link* for an image on
116 /// a page — the pixels only travel directly when the source made them itself
117 /// (a canvas, an image editor), which is why both shapes are handled.
118 pub fn parse_payload(mime: &str, data: &[u8]) -> Option<Payload> {
119     if mime.starts_with("image/") {
120         return Some(Payload::Bytes(data.to_vec()));
121     }
122     if mime.starts_with("text/html") {
123         let html = String::from_utf8_lossy(data);
124         return img_src(&html).map(Payload::Uri);
125     }
126     let text = if mime == "text/x-moz-url" {
127         // Firefox's own flavour is UTF-16LE, "url\ntitle".
128         let units: Vec<u16> = data
129             .chunks_exact(2)
130             .map(|p| u16::from_le_bytes([p[0], p[1]]))
131             .collect();
132         String::from_utf16_lossy(&units)
133     } else {
134         String::from_utf8_lossy(data).into_owned()
135     };
136     // text/uri-list is line-based with '#' comments; the other text flavours
137     // are a bare URL. Taking the first usable line covers both.
138     let uri = text
139         .lines()
140         .map(|l| l.trim())
141         .find(|l| !l.is_empty() && !l.starts_with('#'))?;
142     Some(Payload::Uri(uri.to_string()))
143 }
144 
145 /// Percent-decode enough of a `file://` URI to get a real path back.
146 fn percent_decode(s: &str) -> String {
147     let bytes = s.as_bytes();
148     let mut out = Vec::with_capacity(bytes.len());
149     let mut i = 0;
150     while i < bytes.len() {
151         if bytes[i] == b'%' && i + 2 < bytes.len() {
152             if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
153                 out.push(b);
154                 i += 3;
155                 continue;
156             }
157         }
158         out.push(bytes[i]);
159         i += 1;
160     }
161     String::from_utf8_lossy(&out).into_owned()
162 }
163 
164 /// A filename for the saved copy: the URI's last path segment when it looks
165 /// like a filename, else a generic name. Query strings and fragments are
166 /// stripped — plenty of image URLs end in `?w=800`.
167 fn file_name_for(uri: &str, fallback_ext: &str) -> String {
168     let trimmed = uri.split(['?', '#']).next().unwrap_or(uri);
169     let last = trimmed.rsplit('/').next().unwrap_or("");
170     let last = percent_decode(last);
171     let looks_named = !last.is_empty() && last.contains('.') && last.len() <= 128;
172     if looks_named {
173         last
174     } else {
175         format!("dropped-image.{fallback_ext}")
176     }
177 }
178 
179 /// A path in `dir` that does not exist yet, suffixing `-2`, `-3`, … A drop
180 /// must never overwrite a file the user already has.
181 fn unique_path(dir: &Path, name: &str) -> PathBuf {
182     let candidate = dir.join(name);
183     if !candidate.exists() {
184         return candidate;
185     }
186     let (stem, ext) = match name.rsplit_once('.') {
187         Some((s, e)) => (s.to_string(), format!(".{e}")),
188         None => (name.to_string(), String::new()),
189     };
190     for n in 2..10_000 {
191         let candidate = dir.join(format!("{stem}-{n}{ext}"));
192         if !candidate.exists() {
193             return candidate;
194         }
195     }
196     dir.join(name)
197 }
198 
199 /// Sniff a container from magic bytes — the extension in a URL is a guess and
200 /// content-type is not carried through a drop.
201 fn extension_for(bytes: &[u8]) -> &'static str {
202     if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
203         "png"
204     } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
205         "jpg"
206     } else {
207         "bin"
208     }
209 }
210 
211 /// Fetch the drop's bytes: a local file is read, anything else goes through
212 /// curl. Returns the bytes and the name to save them under.
213 pub fn fetch(payload: Payload) -> std::io::Result<(Vec<u8>, String)> {
214     match payload {
215         Payload::Bytes(bytes) => {
216             let ext = extension_for(&bytes);
217             Ok((bytes, format!("dropped-image.{ext}")))
218         }
219         // Inline data, the way Google Images serves its thumbnails. No fetch
220         // to do — the bytes are in the URI.
221         Payload::Uri(uri) if uri.starts_with("data:") => {
222             let rest = &uri["data:".len()..];
223             let (meta, body) = rest.split_once(',').ok_or_else(|| {
224                 std::io::Error::new(std::io::ErrorKind::InvalidData, "malformed data: URI")
225             })?;
226             let bytes = if meta.ends_with(";base64") {
227                 decode_base64(body).ok_or_else(|| {
228                     std::io::Error::new(std::io::ErrorKind::InvalidData, "bad base64 in data: URI")
229                 })?
230             } else {
231                 percent_decode(body).into_bytes()
232             };
233             let ext = extension_for(&bytes);
234             Ok((bytes, format!("dropped-image.{ext}")))
235         }
236         Payload::Uri(uri) if uri.starts_with("file://") => {
237             let path = percent_decode(uri.trim_start_matches("file://"));
238             let bytes = std::fs::read(&path)?;
239             let name = Path::new(&path)
240                 .file_name()
241                 .map(|n| n.to_string_lossy().into_owned())
242                 .unwrap_or_else(|| file_name_for(&uri, extension_for(&bytes)));
243             Ok((bytes, name))
244         }
245         Payload::Uri(uri) => {
246             if !uri.starts_with("http://") && !uri.starts_with("https://") {
247                 return Err(std::io::Error::new(
248                     std::io::ErrorKind::InvalidInput,
249                     format!("unsupported URI scheme: {uri}"),
250                 ));
251             }
252             let out = std::process::Command::new("curl")
253                 .args([
254                     "--location",
255                     "--fail",
256                     "--silent",
257                     "--show-error",
258                     // A drop should not be able to hang a renderer thread
259                     // forever on a dead host.
260                     "--max-time",
261                     "30",
262                     "--max-filesize",
263                     "67108864",
264                     &uri,
265                 ])
266                 .output()?;
267             if !out.status.success() {
268                 return Err(std::io::Error::other(format!(
269                     "curl failed for {uri}: {}",
270                     String::from_utf8_lossy(&out.stderr).trim()
271                 )));
272             }
273             let name = file_name_for(&uri, extension_for(&out.stdout));
274             Ok((out.stdout, name))
275         }
276     }
277 }
278 
279 /// Standard base64 (RFC 4648) to bytes, tolerating whitespace and padding.
280 /// Hand-rolled rather than pulled in: it is twenty lines, and the only user
281 /// is `data:` URIs.
282 fn decode_base64(s: &str) -> Option<Vec<u8>> {
283     fn val(c: u8) -> Option<u32> {
284         match c {
285             b'A'..=b'Z' => Some((c - b'A') as u32),
286             b'a'..=b'z' => Some((c - b'a') as u32 + 26),
287             b'0'..=b'9' => Some((c - b'0') as u32 + 52),
288             b'+' => Some(62),
289             b'/' => Some(63),
290             _ => None,
291         }
292     }
293     let mut out = Vec::with_capacity(s.len() * 3 / 4);
294     let mut acc: u32 = 0;
295     let mut bits = 0;
296     for c in s.bytes() {
297         if c.is_ascii_whitespace() || c == b'=' {
298             continue;
299         }
300         acc = (acc << 6) | val(c)?;
301         bits += 6;
302         if bits >= 8 {
303             bits -= 8;
304             out.push((acc >> bits) as u8);
305         }
306     }
307     Some(out)
308 }
309 
310 /// Save bytes into the desktop folder under a non-colliding name.
311 pub fn save_to_desktop(bytes: &[u8], name: &str) -> std::io::Result<PathBuf> {
312     let dir = desktop_dir();
313     std::fs::create_dir_all(&dir)?;
314     let path = unique_path(&dir, name);
315     std::fs::write(&path, bytes)?;
316     Ok(path)
317 }
318 
319 /// Resolve a cce binary that is installed beside this one.
320 ///
321 /// This process runs as a systemd user service, whose PATH is
322 /// `/usr/local/bin:/usr/bin` — `~/.local/bin`, where every cce binary is
323 /// installed, is NOT on it. Spawning one by bare name therefore fails with
324 /// ENOENT under systemd while working perfectly from a shell or when the
325 /// compositor spawns it, which is exactly how the context menu shipped
326 /// broken: it worked in every test and never once on the real desktop.
327 fn de_bin(name: &str) -> std::path::PathBuf {
328     if let Ok(exe) = std::env::current_exe() {
329         if let Some(dir) = exe.parent() {
330             let beside = dir.join(name);
331             if beside.exists() {
332                 return beside;
333             }
334         }
335     }
336     // Fall back to PATH: a dev build run straight out of target/ has no cce
337     // binaries beside it, but does have them on PATH.
338     std::path::PathBuf::from(name)
339 }
340 
341 /// Show the context menu for one desktop item and return the chosen action
342 /// id, if any. Runs on a worker thread: it blocks until the menu closes.
343 ///
344 /// The menu is a `cce-cloud --json` popup, the same mechanism the desktop and
345 /// app context menus use, so it looks and behaves like every other menu in the
346 /// DE rather than something this client drew for itself. The pointer's screen
347 /// position has to be asked for — a client knows where its own surface was
348 /// touched, never where that is on the screen.
349 pub fn item_menu(name: &str) -> Option<String> {
350     use std::io::Write;
351 
352     let loc = std::process::Command::new(de_bin("ccectl")).arg("pointer-location").output().ok()?;
353     let loc = String::from_utf8_lossy(&loc.stdout);
354     let coord = |key: &str| -> Option<i32> {
355         loc.split_whitespace()
356             .find_map(|t| t.strip_prefix(key))
357             .and_then(|v| v.trim().parse::<f64>().ok())
358             .map(|v| v.round() as i32)
359     };
360     let (x, y) = (coord("x=")?, coord("y=")?);
361 
362     // The filename is the title so it is clear WHICH image is about to go.
363     let layout = format!(
364         r#"{{"pages":[{{"title":{},"justify":"left","widgets":[
365             {{"type":"button","text":"Remove from Desktop","id":"remove"}}
366         ]}}]}}"#,
367         serde_json::to_string(name).ok()?
368     );
369 
370     let mut child = std::process::Command::new(de_bin("cce-cloud"))
371         .args(["--json", "-x", &x.to_string(), "-y", &y.to_string()])
372         .stdin(std::process::Stdio::piped())
373         .stdout(std::process::Stdio::piped())
374         .stderr(std::process::Stdio::null())
375         .spawn()
376         .ok()?;
377     child.stdin.take()?.write_all(layout.as_bytes()).ok()?;
378     let out = child.wait_with_output().ok()?;
379     let reply: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
380     reply.get("button")?.as_str().map(|s| s.to_string())
381 }
382 
383 /// Tell the user a drop failed, and why. A drop that silently does nothing
384 /// is indistinguishable from one the desktop never received, so every failure
385 /// path goes through here rather than only into the log.
386 pub fn report_failure(reason: &str) {
387     log::warn!("[items] drop failed: {reason}");
388     let _ = std::process::Command::new(de_bin("ccectl"))
389         .args(["notify", "Image not added to the desktop", reason])
390         .stdout(std::process::Stdio::null())
391         .stderr(std::process::Stdio::null())
392         .status();
393 }
394 
395 /// Decode to straight RGBA8 for `cce_ui::vk::upload_rgba`.
396 pub fn decode_rgba(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
397     let img = image::load_from_memory(bytes).ok()?;
398     let rgba = img.to_rgba8();
399     let (w, h) = (rgba.width(), rgba.height());
400     Some((rgba.into_raw(), w, h))
401 }
402 
403 #[cfg(test)]
404 mod tests {
405     use super::*;
406 
407     #[test]
408     fn uri_list_takes_the_first_real_line() {
409         let data = b"# comment\r\nhttps://example.com/cat.png\r\nhttps://other\r\n";
410         let Some(Payload::Uri(u)) = parse_payload("text/uri-list", data) else {
411             panic!("expected a URI")
412         };
413         assert_eq!(u, "https://example.com/cat.png");
414     }
415 
416     #[test]
417     fn moz_url_is_utf16() {
418         // "http://a/b.png\nTitle" in UTF-16LE, as Firefox sends it.
419         let s = "http://a/b.png\nTitle";
420         let data: Vec<u8> = s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
421         let Some(Payload::Uri(u)) = parse_payload("text/x-moz-url", &data) else {
422             panic!("expected a URI")
423         };
424         assert_eq!(u, "http://a/b.png");
425     }
426 
427     #[test]
428     fn image_mimes_carry_bytes_not_links() {
429         let Some(Payload::Bytes(b)) = parse_payload("image/png", &[1, 2, 3]) else {
430             panic!("expected bytes")
431         };
432         assert_eq!(b, vec![1, 2, 3]);
433     }
434 
435     #[test]
436     fn file_names_survive_query_strings_and_fall_back() {
437         assert_eq!(file_name_for("https://x.com/a/cat.png?w=800", "png"), "cat.png");
438         assert_eq!(file_name_for("https://x.com/a/photo%20one.jpg", "jpg"), "photo one.jpg");
439         // No filename in the path at all.
440         assert_eq!(file_name_for("https://x.com/render?id=9", "png"), "dropped-image.png");
441     }
442 
443     #[test]
444     fn html_flavour_names_the_image_not_the_link() {
445         // A Google-Images-shaped fragment: the <img> is inside an <a>, so the
446         // link URL is useless and only the img src names the picture.
447         let html = br#"<a href="/imgres?q=cat"><img src="https://x.com/cat.png" alt="c"></a>"#;
448         let Some(Payload::Uri(u)) = parse_payload("text/html", html) else {
449             panic!("expected a URI")
450         };
451         assert_eq!(u, "https://x.com/cat.png");
452         // Single quotes and no quotes both parse.
453         let Some(Payload::Uri(u)) = parse_payload("text/html", b"<img src='/a.png'>") else {
454             panic!()
455         };
456         assert_eq!(u, "/a.png");
457         // Markup with no image at all is not a drop we can use.
458         assert!(parse_payload("text/html", b"<p>hello</p>").is_none());
459     }
460 
461     #[test]
462     fn data_uris_carry_their_own_bytes() {
463         // "PNG" magic, base64'd, as an inline thumbnail arrives.
464         let png = [0x89u8, b'P', b'N', b'G', 0x0d];
465         let b64 = {
466             const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
467             let mut o = String::new();
468             for c in png.chunks(3) {
469                 let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)];
470                 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
471                 for i in 0..4 {
472                     if i <= c.len() {
473                         o.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char);
474                     } else {
475                         o.push('=');
476                     }
477                 }
478             }
479             o
480         };
481         let uri = format!("data:image/png;base64,{b64}");
482         let (bytes, name) = fetch(Payload::Uri(uri)).expect("data: URI decodes");
483         assert_eq!(&bytes[..5], &png[..]);
484         assert_eq!(name, "dropped-image.png");
485     }
486 
487     #[test]
488     fn base64_roundtrips_known_vectors() {
489         assert_eq!(decode_base64("TWFu").unwrap(), b"Man".to_vec());
490         assert_eq!(decode_base64("TWE=").unwrap(), b"Ma".to_vec());
491         assert_eq!(decode_base64("TW E =\n").unwrap(), b"Ma".to_vec());
492     }
493 
494     #[test]
495     fn extension_is_sniffed_from_content() {
496         assert_eq!(extension_for(&[0x89, b'P', b'N', b'G', 0]), "png");
497         assert_eq!(extension_for(&[0xFF, 0xD8, 0xFF, 0]), "jpg");
498         assert_eq!(extension_for(b"not an image"), "bin");
499     }
500 }