diff --git a/strata-daemon/Cargo.lock b/strata-daemon/Cargo.lock index 8ccf207..3d47ca8 100644 --- a/strata-daemon/Cargo.lock +++ b/strata-daemon/Cargo.lock @@ -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" diff --git a/strata-daemon/Cargo.toml b/strata-daemon/Cargo.toml index 57b4758..b3edbf2 100644 --- a/strata-daemon/Cargo.toml +++ b/strata-daemon/Cargo.toml @@ -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" diff --git a/strata-daemon/src/clipboard/monitor.rs b/strata-daemon/src/clipboard/monitor.rs index e2cd489..d8e944a 100644 --- a/strata-daemon/src/clipboard/monitor.rs +++ b/strata-daemon/src/clipboard/monitor.rs @@ -125,7 +125,9 @@ impl Dispatch 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 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"); diff --git a/strata-daemon/src/db.rs b/strata-daemon/src/db.rs index 94e5371..19ffef5 100644 --- a/strata-daemon/src/db.rs +++ b/strata-daemon/src/db.rs @@ -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 = stmt diff --git a/strata-daemon/src/dbus_service.rs b/strata-daemon/src/dbus_service.rs index 81c62e9..2675729 100644 --- a/strata-daemon/src/dbus_service.rs +++ b/strata-daemon/src/dbus_service.rs @@ -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) -> 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)] diff --git a/strata-daemon/src/main.rs b/strata-daemon/src/main.rs index f8a70ff..8c53b1e 100644 --- a/strata-daemon/src/main.rs +++ b/strata-daemon/src/main.rs @@ -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) };