watch: deprioritize text/html in MIME negotiation

Firefox and Electron apps offer `text/html` first when copying images,
which causes stash to store the HTML wrapper (`<img src="...">`) instead
of the actual image data, which is what we want. We handicap, i.e.,
deprioritize `text/html` in the "any" preference mode and prefer
`image/*` types first, then any non-html type. 

This sounds a little illogical, but in user will almost always prefer
the image itself rather than the text representation. So it's intuitive.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6bd5969344893e15226c27071442475f6a6a6964
This commit is contained in:
raf 2026-02-01 12:25:17 +03:00
commit 9afbe9ceca
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF

View file

@ -140,7 +140,26 @@ fn negotiate_mime_type(
.find(|m| m.starts_with("image/")) .find(|m| m.starts_with("image/"))
.or_else(|| offered.first()) .or_else(|| offered.first())
} else { } else {
offered.first() // XXX: When preference is "any", deprioritize text/html if a more
// concrete type is available. Browsers and Electron apps put
// text/html first even for "Copy Image", but the HTML is just
// a wrapper (<img src="...">), i.e., never what the user wants in a
// clipboard manager. Prefer image/* first, then any non-html
// type, and fall back to text/html only as a last resort.
let has_image = offered.iter().any(|m| m.starts_with("image/"));
if has_image {
offered
.iter()
.find(|m| m.starts_with("image/"))
.or_else(|| offered.first())
} else if offered.first().is_some_and(|m| m == "text/html") {
offered
.iter()
.find(|m| *m != "text/html")
.or_else(|| offered.first())
} else {
offered.first()
}
}; };
match chosen { match chosen {
@ -392,7 +411,20 @@ fn pick_mime<'a>(
.find(|m| m.starts_with("image/")) .find(|m| m.starts_with("image/"))
.or_else(|| offered.first()) .or_else(|| offered.first())
} else { } else {
offered.first() let has_image = offered.iter().any(|m| m.starts_with("image/"));
if has_image {
offered
.iter()
.find(|m| m.starts_with("image/"))
.or_else(|| offered.first())
} else if offered.first().is_some_and(|m| m == "text/html") {
offered
.iter()
.find(|m| *m != "text/html")
.or_else(|| offered.first())
} else {
offered.first()
}
} }
} }
@ -430,17 +462,38 @@ mod tests {
} }
#[test] #[test]
fn test_pick_source_order_preserved() { fn test_pick_image_over_html_firefox_copy_image() {
// Firefox typically offers html first, then image, then text // Firefox "Copy Image" offers html first, then image, then text.
// We should pick the image, not the html wrapper.
let offered = vec![ let offered = vec![
"text/html".to_string(), "text/html".to_string(),
"image/png".to_string(), "image/png".to_string(),
"text/plain".to_string(), "text/plain".to_string(),
]; ];
// With "any", we trust the source: first offered wins assert_eq!(pick_mime(&offered, "any").unwrap(), "image/png");
}
#[test]
fn test_pick_image_over_html_electron() {
// Electron apps also put text/html before image types
let offered = vec!["text/html".to_string(), "image/jpeg".to_string()];
assert_eq!(pick_mime(&offered, "any").unwrap(), "image/jpeg");
}
#[test]
fn test_pick_html_fallback_when_only_html() {
// When text/html is the only type, pick it
let offered = vec!["text/html".to_string()];
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/html"); assert_eq!(pick_mime(&offered, "any").unwrap(), "text/html");
} }
#[test]
fn test_pick_text_over_html_when_no_image() {
// Rich text copy: html + plain, no image — prefer plain text
let offered = vec!["text/html".to_string(), "text/plain".to_string()];
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/plain");
}
#[test] #[test]
fn test_pick_file_manager_uri_list_first() { fn test_pick_file_manager_uri_list_first() {
// File managers typically offer uri-list first // File managers typically offer uri-list first