desktop grid client
git clone https://git.lucas.co/cce-grid.git
fix: dropped images from Chrome/Google Images, and stop failing silently
A drag from Google Images reached the desktop (the compositor logged the
drag start and 42 focus hand-offs to the grid) and then nothing happened,
with no clue anywhere as to why. Three things were wrong:
- Failures were invisible. env_logger defaults to error-only and this
process runs unattended as a session service, so a drop that failed
looked exactly like a drop that was never delivered. It now defaults to
info, and every failure path reports to the user through a notification
saying what it could not do.
- data: URIs were rejected outright as an unsupported scheme. Google
Images inlines its thumbnails that way, so the common case could not
work at all. Now decoded in place — the bytes are already in the URI.
- text/uri-list is preferred less than text/html. For a thumbnail wrapped
in a link — Google Images' exact markup — uri-list is the RESULT PAGE,
so fetching it returns HTML rather than a picture. text/html names the
image itself via <img src>. For an unwrapped image the two agree, so
this never does worse, and raw image/* is still preferred over both.
Shadow-verified: Firefox dropping a linked image still takes image/png
and pins it, unchanged by the reordering.
Co-Authored-By: Claude Opus 5 <[email protected]>
src/items.rs | 148 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main.rs | 31 +++++++++++--
2 files changed, 174 insertions(+), 5 deletions(-)
diff --git a/src/items.rs b/src/items.rs
index 4fe43ae..f5f74f7 100644
--- a/src/items.rs
+++ b/src/items.rs
@@ -78,6 +78,39 @@ pub enum Payload {
Bytes(Vec<u8>),
}
+/// The `src` of the first `<img>` in a fragment of HTML. Browsers offer
+/// `text/html` alongside the URL flavours, and it is the only one that names
+/// the IMAGE when the image is wrapped in a link — which is exactly how a
+/// Google Images thumbnail is marked up, so `text/uri-list` there is the
+/// result page, not the picture.
+fn img_src(html: &str) -> Option<String> {
+ let lower = html.to_ascii_lowercase();
+ let mut from = 0;
+ while let Some(tag) = lower[from..].find("<img") {
+ let tag = from + tag;
+ let rest = &lower[tag..];
+ let end = rest.find('>').map(|e| tag + e).unwrap_or(lower.len());
+ if let Some(src) = lower[tag..end].find("src") {
+ let after = tag + src + 3;
+ let seg = &html[after..end.min(html.len())];
+ // src = "..." | '...' | bare
+ let seg = seg.trim_start().strip_prefix('=')?.trim_start();
+ let value = match seg.chars().next() {
+ Some('"') => seg[1..].split('"').next(),
+ Some('\'') => seg[1..].split('\'').next(),
+ _ => seg.split_whitespace().next(),
+ };
+ if let Some(v) = value {
+ if !v.is_empty() {
+ return Some(v.to_string());
+ }
+ }
+ }
+ from = end.max(tag + 4);
+ }
+ None
+}
+
/// Interpret a drop by mime type. Browsers hand over a *link* for an image on
/// a page — the pixels only travel directly when the source made them itself
/// (a canvas, an image editor), which is why both shapes are handled.
@@ -85,6 +118,10 @@ pub fn parse_payload(mime: &str, data: &[u8]) -> Option<Payload> {
if mime.starts_with("image/") {
return Some(Payload::Bytes(data.to_vec()));
}
+ if mime.starts_with("text/html") {
+ let html = String::from_utf8_lossy(data);
+ return img_src(&html).map(Payload::Uri);
+ }
let text = if mime == "text/x-moz-url" {
// Firefox's own flavour is UTF-16LE, "url\ntitle".
let units: Vec<u16> = data
@@ -178,6 +215,23 @@ pub fn fetch(payload: Payload) -> std::io::Result<(Vec<u8>, String)> {
let ext = extension_for(&bytes);
Ok((bytes, format!("dropped-image.{ext}")))
}
+ // Inline data, the way Google Images serves its thumbnails. No fetch
+ // to do — the bytes are in the URI.
+ Payload::Uri(uri) if uri.starts_with("data:") => {
+ let rest = &uri["data:".len()..];
+ let (meta, body) = rest.split_once(',').ok_or_else(|| {
+ std::io::Error::new(std::io::ErrorKind::InvalidData, "malformed data: URI")
+ })?;
+ let bytes = if meta.ends_with(";base64") {
+ decode_base64(body).ok_or_else(|| {
+ std::io::Error::new(std::io::ErrorKind::InvalidData, "bad base64 in data: URI")
+ })?
+ } else {
+ percent_decode(body).into_bytes()
+ };
+ let ext = extension_for(&bytes);
+ Ok((bytes, format!("dropped-image.{ext}")))
+ }
Payload::Uri(uri) if uri.starts_with("file://") => {
let path = percent_decode(uri.trim_start_matches("file://"));
let bytes = std::fs::read(&path)?;
@@ -221,6 +275,37 @@ pub fn fetch(payload: Payload) -> std::io::Result<(Vec<u8>, String)> {
}
}
+/// Standard base64 (RFC 4648) to bytes, tolerating whitespace and padding.
+/// Hand-rolled rather than pulled in: it is twenty lines, and the only user
+/// is `data:` URIs.
+fn decode_base64(s: &str) -> Option<Vec<u8>> {
+ fn val(c: u8) -> Option<u32> {
+ match c {
+ b'A'..=b'Z' => Some((c - b'A') as u32),
+ b'a'..=b'z' => Some((c - b'a') as u32 + 26),
+ b'0'..=b'9' => Some((c - b'0') as u32 + 52),
+ b'+' => Some(62),
+ b'/' => Some(63),
+ _ => None,
+ }
+ }
+ let mut out = Vec::with_capacity(s.len() * 3 / 4);
+ let mut acc: u32 = 0;
+ let mut bits = 0;
+ for c in s.bytes() {
+ if c.is_ascii_whitespace() || c == b'=' {
+ continue;
+ }
+ acc = (acc << 6) | val(c)?;
+ bits += 6;
+ if bits >= 8 {
+ bits -= 8;
+ out.push((acc >> bits) as u8);
+ }
+ }
+ Some(out)
+}
+
/// Save bytes into the desktop folder under a non-colliding name.
pub fn save_to_desktop(bytes: &[u8], name: &str) -> std::io::Result<PathBuf> {
let dir = desktop_dir();
@@ -230,6 +315,18 @@ pub fn save_to_desktop(bytes: &[u8], name: &str) -> std::io::Result<PathBuf> {
Ok(path)
}
+/// Tell the user a drop failed, and why. A drop that silently does nothing
+/// is indistinguishable from one the desktop never received, so every failure
+/// path goes through here rather than only into the log.
+pub fn report_failure(reason: &str) {
+ log::warn!("[items] drop failed: {reason}");
+ let _ = std::process::Command::new("ccectl")
+ .args(["notify", "Image not added to the desktop", reason])
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .status();
+}
+
/// Decode to straight RGBA8 for `cce_ui::vk::upload_rgba`.
pub fn decode_rgba(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
let img = image::load_from_memory(bytes).ok()?;
@@ -278,6 +375,57 @@ mod tests {
assert_eq!(file_name_for("https://x.com/render?id=9", "png"), "dropped-image.png");
}
+ #[test]
+ fn html_flavour_names_the_image_not_the_link() {
+ // A Google-Images-shaped fragment: the <img> is inside an <a>, so the
+ // link URL is useless and only the img src names the picture.
+ let html = br#"<a href="/imgres?q=cat"><img src="https://x.com/cat.png" alt="c"></a>"#;
+ let Some(Payload::Uri(u)) = parse_payload("text/html", html) else {
+ panic!("expected a URI")
+ };
+ assert_eq!(u, "https://x.com/cat.png");
+ // Single quotes and no quotes both parse.
+ let Some(Payload::Uri(u)) = parse_payload("text/html", b"<img src='/a.png'>") else {
+ panic!()
+ };
+ assert_eq!(u, "/a.png");
+ // Markup with no image at all is not a drop we can use.
+ assert!(parse_payload("text/html", b"<p>hello</p>").is_none());
+ }
+
+ #[test]
+ fn data_uris_carry_their_own_bytes() {
+ // "PNG" magic, base64'd, as an inline thumbnail arrives.
+ let png = [0x89u8, b'P', b'N', b'G', 0x0d];
+ let b64 = {
+ const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ let mut o = String::new();
+ for c in png.chunks(3) {
+ let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)];
+ let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
+ for i in 0..4 {
+ if i <= c.len() {
+ o.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char);
+ } else {
+ o.push('=');
+ }
+ }
+ }
+ o
+ };
+ let uri = format!("data:image/png;base64,{b64}");
+ let (bytes, name) = fetch(Payload::Uri(uri)).expect("data: URI decodes");
+ assert_eq!(&bytes[..5], &png[..]);
+ assert_eq!(name, "dropped-image.png");
+ }
+
+ #[test]
+ fn base64_roundtrips_known_vectors() {
+ assert_eq!(decode_base64("TWFu").unwrap(), b"Man".to_vec());
+ assert_eq!(decode_base64("TWE=").unwrap(), b"Ma".to_vec());
+ assert_eq!(decode_base64("TW E =\n").unwrap(), b"Ma".to_vec());
+ }
+
#[test]
fn extension_is_sniffed_from_content() {
assert_eq!(extension_for(&[0x89, b'P', b'N', b'G', 0]), "png");
diff --git a/src/main.rs b/src/main.rs
index b979958..d9cfd0b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -393,8 +393,18 @@ impl Application for GridApp {
/// bytes if the source has them, else a link to fetch.
fn drop_mimes(&self) -> &'static [&'static str] {
&[
+ // Pixels beat links: no fetch, no ambiguity. Firefox offers these
+ // for an image on a page; Chrome usually does not.
"image/png",
"image/jpeg",
+ "image/gif",
+ "image/webp",
+ // Preferred over text/uri-list because it names the IMAGE. When a
+ // thumbnail is wrapped in a link — Google Images' exact markup —
+ // uri-list is the result page and fetching it yields HTML, not a
+ // picture. For an unwrapped image the two agree, so this never
+ // does worse.
+ "text/html",
"text/uri-list",
"text/x-moz-url",
"text/plain;charset=utf-8",
@@ -413,7 +423,10 @@ impl Application for GridApp {
if patch.scale <= 0.0 {
return;
}
- let Some(payload) = items::parse_payload(mime, data) else { return };
+ let Some(payload) = items::parse_payload(mime, data) else {
+ items::report_failure(&format!("nothing usable in the dropped {mime}"));
+ return;
+ };
// The drop point in world coordinates — the inverse of the mapping
// `paint` uses to place cells, so the image lands under the cursor
@@ -426,16 +439,20 @@ impl Application for GridApp {
let st = style();
let (cell_w, cell_h) = (st.cell_w.max(16.0), st.cell_h.max(16.0));
let sender = self.sender.clone();
+ let mime = mime.to_string();
std::thread::spawn(move || {
let (bytes, name) = match items::fetch(payload) {
Ok(v) => v,
Err(e) => {
- log::warn!("[items] fetch failed: {e}");
+ items::report_failure(&format!("could not fetch it: {e}"));
return;
}
};
let Some((pixels, px_w, px_h)) = items::decode_rgba(&bytes) else {
- log::warn!("[items] dropped data is not a decodable image");
+ items::report_failure(&format!(
+ "the dropped {mime} is not an image cce can read ({} bytes)",
+ bytes.len()
+ ));
return;
};
// Save even though it is already decoded: the user asked for the
@@ -443,7 +460,7 @@ impl Application for GridApp {
let path = match items::save_to_desktop(&bytes, &name) {
Ok(p) => p,
Err(e) => {
- log::warn!("[items] could not save to the desktop folder: {e}");
+ items::report_failure(&format!("could not save it to the desktop: {e}"));
return;
}
};
@@ -506,6 +523,10 @@ impl Application for GridApp {
}
fn main() {
- env_logger::init();
+ // Default to info, not env_logger's error-only: this process runs
+ // unattended as a session service, and a drop that quietly fails with
+ // nothing in the log is indistinguishable from a drop that never
+ // happened — which is exactly how the first Chrome failure presented.
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
cce_ui::engine::run::<GridApp>();
}