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 }