fix: daemon image decoding, preview alignment, ordering and lifecycle

Audit fixes for the daemon:

- Decode gif/webp/bmp/tiff/ico (enable the pure-Rust image features). These
  mimes were accepted by pick_mime but the image crate only had png/jpeg, so
  thumbnail decode failed and the item was silently dropped. Drop avif (needs
  heavy C deps) and svg (not a raster format the image crate decodes) from the
  allowlist so unsupported types are never selected.
- ItemAdded signal preview now uses db::PREVIEW_CHARS (200), matching the
  history/search queries, so a just-copied row and the same row after a reload
  carry identical text.
- Add a deterministic ", rowid DESC" tiebreaker to the three created_at DESC
  orderings (history, search, prune) so items sharing a millisecond have a
  stable order and prune can't evict the wrong one at the boundary.
- SetConfig now prunes immediately (and emits ItemDeleted) when max_history is
  lowered, instead of waiting for the next copy.
- Request the bus name with AllowReplacement (plus ReplaceExisting) so a
  reload's new instance can cleanly take the name; fix the misleading comment.
- Wayland monitor clears its pending-offers map on Selection{None} so it can't
  grow across copy-then-clear cycles (wlroots path).
- Document that SubmitItem is a trusted-caller method (the password-manager
  hint is enforced client-side; forwarding it is a future contract change).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Eduard Tolosa 2026-05-26 17:29:29 -05:00
parent 46ce549b2f
commit e7b0392d92
6 changed files with 125 additions and 14 deletions

View file

@ -289,6 +289,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@ -337,6 +343,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@ -466,6 +478,12 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "fdeflate"
version = "0.3.7"
@ -589,6 +607,26 @@ dependencies = [
"wasip3",
]
[[package]]
name = "gif"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "half"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9"
dependencies = [
"cfg-if",
"crunchy",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@ -654,13 +692,27 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@ -1010,6 +1062,12 @@ version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.39.4"
@ -1365,6 +1423,20 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]]
name = "tokio"
version = "1.52.3"
@ -1727,6 +1799,12 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "windows-link"
version = "0.2.1"

View file

@ -31,7 +31,7 @@ serde_json = "1"
uuid = { version = "1", features = ["v4"] }
# Image thumbnail generation (runs in spawn_blocking — never blocks async runtime)
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"] }
# Fast content hashing for deduplication
blake3 = "1"

View file

@ -125,7 +125,9 @@ impl Dispatch<ExtDataControlDeviceV1, ()> for MonitorState {
offer.destroy();
}
ext_data_control_device_v1::Event::Selection { id: None } => {
// null id means "clipboard cleared" - ignore.
// null id means "clipboard cleared". Drop any accumulated offers
// so the map can't grow across copy-then-clear cycles.
state.offers.clear();
}
ext_data_control_device_v1::Event::Finished => {
tracing::warn!("ext-data-control device finished (compositor revoked access)");
@ -184,7 +186,9 @@ impl Dispatch<ZwlrDataControlDeviceV1, ()> for MonitorState {
state.commit_selection(oid);
offer.destroy();
}
zwlr_data_control_device_v1::Event::Selection { id: None } => {}
zwlr_data_control_device_v1::Event::Selection { id: None } => {
state.offers.clear();
}
zwlr_data_control_device_v1::Event::Finished => {
tracing::warn!("zwlr-data-control device finished");

View file

@ -9,7 +9,7 @@ use std::sync::{Mutex, MutexGuard};
/// it is fetched on demand via `get_raw_item` / `GetItemContent` for paste-back.
/// Keeping the trim in SQL means a page of large text items costs a few KB of
/// JSON, not megabytes, and the work stays in the daemon.
const PREVIEW_CHARS: usize = 200;
pub const PREVIEW_CHARS: usize = 200;
/// Metadata for a clipboard entry - does NOT include thumbnail bytes.
/// Thumbnails are fetched separately via `get_thumbnail` for lazy loading.
@ -185,7 +185,7 @@ impl Db {
"SELECT id, mime_type, substr(content_text, 1, {PREVIEW_CHARS}), source_app, created_at,
thumbnail_blob IS NOT NULL AS has_thumb
FROM clipboard_history
ORDER BY created_at DESC
ORDER BY created_at DESC, rowid DESC
LIMIT ?1 OFFSET ?2"
);
let mut stmt = conn.prepare(&sql)?;
@ -233,7 +233,7 @@ impl Db {
h.thumbnail_blob IS NOT NULL AS has_thumb
FROM clipboard_history h
WHERE h.rowid IN (SELECT rowid FROM clipboard_fts WHERE clipboard_fts MATCH ?1)
ORDER BY h.created_at DESC
ORDER BY h.created_at DESC, h.rowid DESC
LIMIT ?2"
);
let mut stmt = conn.prepare(&sql)?;
@ -320,7 +320,7 @@ impl Db {
let mut stmt = conn.prepare(
"SELECT id FROM clipboard_history
WHERE id NOT IN (
SELECT id FROM clipboard_history ORDER BY created_at DESC LIMIT ?1
SELECT id FROM clipboard_history ORDER BY created_at DESC, rowid DESC LIMIT ?1
)",
)?;
let ids: Vec<String> = stmt

View file

@ -61,6 +61,12 @@ pub struct StrataManager {
impl StrataManager {
/// Called by the GJS extension when it detects a clipboard change via Meta.Selection.
/// `content` is the raw bytes of the clipboard payload (D-Bus `ay`).
///
/// Trust note: the password-manager hint (`x-kde-passwordManagerHint`) is
/// honored client-side by the extension before it ever calls this, but the
/// hint is not forwarded, so this method cannot re-check it. Treat callers of
/// `SubmitItem` as trusted (the session bus is per-user). Forwarding the
/// sensitivity flag to enforce it here is left for a future contract change.
async fn submit_item(&self, mime_type: String, content: Vec<u8>) -> zbus::fdo::Result<()> {
self.submit_tx
.send(SubmitRequest {
@ -205,6 +211,7 @@ impl StrataManager {
max_history: u32,
max_text_bytes: u32,
max_image_bytes: u32,
#[zbus(signal_context)] ctx: SignalContext<'_>,
) -> zbus::fdo::Result<()> {
use std::sync::atomic::Ordering;
if max_history > 0 {
@ -228,6 +235,23 @@ impl StrataManager {
self.limits.max_text_bytes(),
self.limits.max_image_bytes(),
);
// Lowering max_history should shrink stored history immediately, not
// wait until the next copy triggers a prune. Prune now and emit
// ItemDeleted for each removed id so clients can drop their caches.
if max_history > 0 {
let db = self.db.clone();
let max = max_history as usize;
let pruned = tokio::task::spawn_blocking(move || db.prune(max))
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
for id in &pruned {
if let Err(e) = Self::item_deleted(&ctx, id).await {
tracing::warn!("Emitting ItemDeleted for pruned id={}: {}", id, e);
}
}
}
Ok(())
}
@ -244,7 +268,7 @@ impl StrataManager {
// -----------------------------------------------------------------
/// Emitted when a new item is stored (after dedup + thumbnail generation).
/// `preview` is a text excerpt (≤120 chars) for text items, or empty for
/// `preview` is a text excerpt (≤ PREVIEW_CHARS) for text items, or empty for
/// images and other binary types - clients should call GetThumbnail(id) to
/// fetch image thumbnails lazily.
#[zbus(signal)]

View file

@ -54,12 +54,16 @@ async fn main() -> Result<()> {
.await
.context("Building D-Bus connection")?;
// Request the well-known name with ReplaceExisting so a new instance always
// wins the race against the outgoing instance during extension reload.
// Request the well-known name with ReplaceExisting (try to take it from any
// outgoing instance) AND AllowReplacement (let the next instance take it
// from us). Both are needed for a clean hand-off on extension reload: without
// AllowReplacement on the running instance, a new instance's ReplaceExisting
// would be refused.
dbus_conn
.request_name_with_flags(
"org.gnome.Strata",
zbus::fdo::RequestNameFlags::ReplaceExisting.into(),
zbus::fdo::RequestNameFlags::ReplaceExisting
| zbus::fdo::RequestNameFlags::AllowReplacement,
)
.await
.context("Acquiring D-Bus name org.gnome.Strata")?;
@ -152,9 +156,7 @@ fn pick_mime(mimes: &[String]) -> Option<&str> {
"image/webp",
"image/bmp",
"image/tiff",
"image/avif",
"image/x-icon",
"image/svg+xml",
// Plain text (UTF-8 preferred, then locale, then X11 legacy aliases).
// Ranked above rich-text so editors that expose both text/plain and
// text/html give us the raw source, not styled markup.
@ -271,7 +273,10 @@ async fn process_bytes(
(None, Some(raw_bytes), Some(thumb_bytes), String::new())
} else {
let text = String::from_utf8_lossy(&raw_bytes).into_owned();
let preview: String = text.chars().take(120).collect();
// Same preview length as the paginated/search queries (db::PREVIEW_CHARS),
// so a just-copied row and the same row after a reload carry identical
// text. `take` is lazy, so this never scans past the first N chars.
let preview: String = text.chars().take(db::PREVIEW_CHARS).collect();
(Some(text), None, None, preview)
};