diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4a87909..d5cd21a 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -44,19 +44,21 @@ assets/ .desktop file, icons, AppStream metainfo The scaffold is functional with image display, async OCR pipeline, drag-select overlay, quick preview mode, and directory navigation. See `docs/PHASED_PLAN.md` for what's done and what's next. ### What works: -- Image loading and display (GdkTexture) +- Async, sandboxed image loading (glycin loader process when installed, + runtime-probed once per session; in-process GDK decoding on a worker thread + otherwise — fallback is session-wide only, never per-file) - Quick Preview window (borderless, layer-shell, Space/Esc dismiss) - Full Viewer window (headerbar, arrow key navigation) +- File info in the headerbar (filename, dimensions, file size) - Async OCR (Tesseract TSV → word bounding boxes) - Drag-select overlay with word highlighting -- Ctrl+C clipboard copy -- Stale OCR result cancellation via monotonic job IDs +- Ctrl+C clipboard copy and right-click context menu (Copy / Copy All Text) +- Stale decode and OCR result cancellation via monotonic job IDs - Zoom & pan (Ctrl+scroll, pinch, +/- keys, middle-drag pan) via custom `ZoomableCanvas` widget ### What's not implemented yet: -- Info panel (filename, dimensions, file size) -- Context menu (right-click copy) - OCR caching (cache module exists but is not wired up) +- Quick Preview click-outside-to-close and single-instance toggle - Performance benchmarks ## Development diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c45730a..94bf512 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,8 @@ jobs: rustup \ gtk4 libadwaita \ tesseract tesseract-data-eng \ - gtk4-layer-shell + gtk4-layer-shell \ + libseccomp lcms2 fontconfig pkg-config --atleast-version=4.10 gtk4 rustup default stable @@ -74,24 +75,37 @@ jobs: rustup \ gtk4 libadwaita \ tesseract tesseract-data-eng \ - gtk4-layer-shell + gtk4-layer-shell \ + libseccomp lcms2 fontconfig pkg-config --atleast-version=4.10 gtk4 rustup default stable - - name: Cache cargo registry, build artifacts, and tools + - name: Cache cargo registry and build artifacts uses: actions/cache@v4 with: path: | /root/.cargo/registry /root/.cargo/git - /root/.cargo/bin target key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo-build- + # Prebuilt static binary: `cargo install` recompiles cargo-deny from + # source on every cache miss or upstream release (~100s per run). + # Checksum is pinned here (not fetched from the release) so a swapped + # artifact fails loudly. When bumping DENY_VERSION, update it from the + # release's .tar.gz.sha256 asset. - name: Install cargo-deny - run: cargo install cargo-deny --locked + run: | + DENY_VERSION=0.19.9 + DENY_SHA256=f1f8eedc2a3ac297c540873f93785d4104b102c0079506b2a6b3221b7ec956af + curl -sSfL -o /tmp/cargo-deny.tar.gz \ + "https://github.com/EmbarkStudios/cargo-deny/releases/download/${DENY_VERSION}/cargo-deny-${DENY_VERSION}-x86_64-unknown-linux-musl.tar.gz" + echo "${DENY_SHA256} /tmp/cargo-deny.tar.gz" | sha256sum -c - + tar -xz -C /usr/local/bin --strip-components=1 \ + -f /tmp/cargo-deny.tar.gz \ + "cargo-deny-${DENY_VERSION}-x86_64-unknown-linux-musl/cargo-deny" - name: Dependency audit run: cargo deny check diff --git a/Cargo.lock b/Cargo.lock index a1327a8..a40f77c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -937,6 +937,7 @@ dependencies = [ "futures-lite", "futures-timer", "futures-util", + "gdk4", "gio", "glib", "glycin-common", diff --git a/README.md b/README.md index b691168..57c8231 100644 --- a/README.md +++ b/README.md @@ -48,12 +48,13 @@ sudo pacman -S --needed \ base-devel rustup pkgconf \ gtk4 libadwaita \ tesseract tesseract-data-eng \ - gtk4-layer-shell + gtk4-layer-shell \ + libseccomp lcms2 fontconfig ``` Optional: - `wl-clipboard` — CLI clipboard fallback -- `glycin` — sandboxed image decoding (future) +- `glycin` — sandboxed image decoding (recommended; detected at runtime, QuickView falls back to in-process GDK decoding without it) > `gtk4-layer-shell` provides true overlay behavior on wlroots compositors. On GNOME/Mutter, Quick Preview falls back to an undecorated window. diff --git a/adrs/ADR-0004-Image-Decoding.md b/adrs/ADR-0004-Image-Decoding.md index 35815a9..ed6a948 100644 --- a/adrs/ADR-0004-Image-Decoding.md +++ b/adrs/ADR-0004-Image-Decoding.md @@ -20,6 +20,22 @@ Prefer **`glycin`** for decoding where available, with a fallback to GTK/GDK def Glycin decodes via sandboxed modular loaders, improving safety when handling untrusted images. +## Implementation notes (2026-07-05) + +- The `glycin` client crate is **always compiled**; no cargo feature. Which + backend is used is decided **at runtime** by probing the installed loader + configs once per session (`quickview-ui/src/decode.rs`). The client links + libseccomp, lcms2, and fontconfig — ubiquitous on any GTK-capable system, + and required at build time regardless of which backend runs (see + `docs/DEPENDENCIES.md`). +- The GDK fallback is strictly **session-wide, never per-file**: a file glycin + rejects is a failed load. Retrying it with the unsandboxed GDK decoder would + let a crafted image reach the unsandboxed path simply by making the sandboxed + loader fail. +- Both backends run off the GTK main thread (glycin in its sandboxed loader + process, GDK on a worker thread); stale decodes are dropped via a monotonic + job ID, the same pattern used for OCR (ADR-0010). + ## Consequences ### Positive diff --git a/crates/quickview-ui/Cargo.toml b/crates/quickview-ui/Cargo.toml index c350b37..091c78b 100644 --- a/crates/quickview-ui/Cargo.toml +++ b/crates/quickview-ui/Cargo.toml @@ -5,11 +5,6 @@ edition.workspace = true rust-version.workspace = true license.workspace = true -[features] -# Optional safer image decoding path. -# Disabled by default in this scaffold to keep builds simple. -glycin = ["dep:glycin"] - [dependencies] anyhow.workspace = true tracing.workspace = true @@ -23,5 +18,7 @@ gtk4-layer-shell = "0.7" glib = "0.21" async-channel = "2" -# Optional: sandboxed image decoding (requires system glycin libs) -glycin = { version = "3", optional = true } +# Sandboxed image decoding. Always compiled; whether it is used is decided at +# runtime by probing installed loaders. Links libseccomp, lcms2, and +# fontconfig (see docs/DEPENDENCIES.md). +glycin = { version = "3", features = ["gdk4"] } diff --git a/crates/quickview-ui/src/decode.rs b/crates/quickview-ui/src/decode.rs new file mode 100644 index 0000000..ecd2a10 --- /dev/null +++ b/crates/quickview-ui/src/decode.rs @@ -0,0 +1,93 @@ +//! Async image decoding off the GTK main thread. +//! +//! Two backends, chosen once per session (ADR-0004): +//! - **glycin** — decodes in a sandboxed loader process. Used whenever the +//! system has glycin loaders installed. +//! - **GDK** — in-process `gdk::Texture` decoding on a worker thread. Used +//! only when the loader probe finds no glycin loaders for this session. +//! +//! The fallback is strictly session-wide, never per-file: a file glycin +//! rejects is a failed load. Re-feeding it to the unsandboxed GDK decoder +//! would let a crafted image reach the unsandboxed path simply by making the +//! sandboxed loader fail (NFR-002). + +use std::{cell::Cell, path::Path}; + +use gtk4 as gtk; + +use gtk::{gdk, gio}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Backend { + Glycin, + Gdk, +} + +/// The session-wide decode backend, probed on first use. +/// +/// Main-thread only, like the rest of the UI crate. Concurrent first calls +/// may both run the probe; that is harmless (same result, cached once). +pub async fn backend() -> Backend { + thread_local! { + static BACKEND: Cell> = const { Cell::new(None) }; + } + + if let Some(backend) = BACKEND.get() { + return backend; + } + + let backend = if glycin::Loader::supported_mime_types().await.is_empty() { + tracing::warn!( + "image decoding: no glycin loaders installed; \ + falling back to in-process GDK decoding (unsandboxed)" + ); + Backend::Gdk + } else { + tracing::info!("image decoding: glycin (sandboxed loader process)"); + Backend::Glycin + }; + BACKEND.set(Some(backend)); + backend +} + +/// Decode `path` into a texture without blocking the UI. +/// +/// Await this from `glib::MainContext::spawn_local`; the actual decode work +/// happens in a sandboxed glycin process or on a worker thread. +pub async fn decode_texture(path: &Path) -> anyhow::Result { + match backend().await { + Backend::Glycin => decode_glycin(path).await, + Backend::Gdk => decode_gdk(path).await, + } +} + +async fn decode_glycin(path: &Path) -> anyhow::Result { + let file = gio::File::for_path(path); + let image = glycin::Loader::new(file) + .load() + .await + .map_err(|err| anyhow::anyhow!("glycin load failed: {err}"))?; + let frame = image + .next_frame() + .await + .map_err(|err| anyhow::anyhow!("glycin frame decode failed: {err}"))?; + Ok(frame.texture()) +} + +async fn decode_gdk(path: &Path) -> anyhow::Result { + let (sender, receiver) = async_channel::bounded::>(1); + + let path = path.to_path_buf(); + // GdkTexture is immutable and thread-safe, so it can be created on a + // worker thread and sent back to the main thread. + std::thread::spawn(move || { + let result = gdk::Texture::from_filename(&path); + let _ = sender.send_blocking(result); + }); + + let result = receiver + .recv() + .await + .map_err(|_| anyhow::anyhow!("GDK decode thread terminated unexpectedly"))?; + Ok(result?) +} diff --git a/crates/quickview-ui/src/lib.rs b/crates/quickview-ui/src/lib.rs index 628306c..91f052d 100644 --- a/crates/quickview-ui/src/lib.rs +++ b/crates/quickview-ui/src/lib.rs @@ -6,6 +6,7 @@ use anyhow::Result; use adw::prelude::*; +mod decode; pub mod widgets; pub mod windows; diff --git a/crates/quickview-ui/src/windows/shared.rs b/crates/quickview-ui/src/windows/shared.rs index c9f1a2b..caa7fd7 100644 --- a/crates/quickview-ui/src/windows/shared.rs +++ b/crates/quickview-ui/src/windows/shared.rs @@ -41,7 +41,8 @@ pub struct ViewerController { ocr_lang: Rc>, - // Monotonic id to ignore late OCR results. + // Monotonic ids to ignore late results from superseded jobs. + decode_job_id: Rc>, ocr_job_id: Rc>, on_file_loaded: Rc>>, @@ -61,6 +62,7 @@ impl ViewerController { dir_images: Rc::new(RefCell::new(dir_images)), dir_index: Rc::new(Cell::new(dir_index)), ocr_lang: Rc::new(RefCell::new(ocr_lang)), + decode_job_id: Rc::new(Cell::new(0)), ocr_job_id: Rc::new(Cell::new(0)), on_file_loaded: Rc::new(RefCell::new(None)), last_file_info: Rc::new(RefCell::new(None)), @@ -110,9 +112,41 @@ impl ViewerController { .unwrap_or_else(|| path.display().to_string()); let size_bytes = std::fs::metadata(path).ok().map(|m| m.len()); - // Load image synchronously for now. - let file = gtk::gio::File::for_path(path); - match gtk::gdk::Texture::from_file(&file) { + // Supersede any in-flight decode. + let decode_id = self.decode_job_id.get().wrapping_add(1); + self.decode_job_id.set(decode_id); + // Also invalidate any in-flight OCR from the previous image: its late + // result would otherwise clear the busy spinner mid-decode (and, on a + // failed load, repopulate the cleared canvas). + self.ocr_job_id.set(self.ocr_job_id.get().wrapping_add(1)); + + // The previous image stays visible under the busy spinner until the + // decode finishes; the spinner then carries through into OCR. + self.overlay.set_ocr_busy(true); + + let this = self.clone(); + let path = path.to_path_buf(); + glib::MainContext::default().spawn_local(async move { + let result = crate::decode::decode_texture(&path).await; + this.finish_decode(decode_id, path, name, size_bytes, result); + }); + } + + /// Apply a finished decode, unless a newer `load_file` superseded it. + fn finish_decode( + &self, + job_id: u64, + path: PathBuf, + name: String, + size_bytes: Option, + result: anyhow::Result, + ) { + if job_id != self.decode_job_id.get() { + // Late result for a file the user already navigated away from. + return; + } + + match result { Ok(texture) => { let info = FileInfo { name, @@ -123,12 +157,10 @@ impl ViewerController { }; self.overlay.set_texture(texture); self.emit_file_loaded(info); + self.start_ocr(path); } Err(err) => { - tracing::error!("Failed to load image: {err}"); - // Invalidate any in-flight OCR job from the previous image so - // a late result can't repopulate the cleared canvas. - self.ocr_job_id.set(self.ocr_job_id.get().wrapping_add(1)); + tracing::error!("Failed to load image: {err:#}"); // Clear the stale image (and its OCR state) so the canvas // matches the load_failed info shown in the headerbar. self.overlay.clear_texture(); @@ -140,11 +172,8 @@ impl ViewerController { size_bytes, load_failed: true, }); - return; } } - - self.start_ocr(path.to_path_buf()); } pub fn next_image(&self) { diff --git a/deny.toml b/deny.toml index 1d4162a..84b62fc 100644 --- a/deny.toml +++ b/deny.toml @@ -2,7 +2,13 @@ # https://embarkstudios.github.io/cargo-deny/ [advisories] -ignore = [] +ignore = [ + # `paste` (proc-macro, build-time only) is archived/unmaintained. It is a + # transitive dependency of glycin (via glycin-common/glycin-utils/gufo); + # no fixed release exists. Drop this once glycin's deps migrate to the + # `pastey` fork. + "RUSTSEC-2024-0436", +] [licenses] allow = [ diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 70ad51f..b85ba03 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -16,8 +16,10 @@ For more detail, see the ADRs in `adrs/`. - **OS targets:** Wayland-only (no X11 fallback for v1) ### Image rendering/decoding -- **Decode library (preferred, optional):** **glycin** (sandboxed modular loaders) -- **Fallback decoder (default in this scaffold):** GTK/GDK `Texture::from_file` path +- **Decode library (default when loaders are installed):** **glycin** (sandboxed + modular loaders; probed once per session at runtime) +- **Fallback decoder (session-wide only, never per-file):** GTK/GDK texture + decoding on a worker thread ### OCR - **Default OCR engine:** Tesseract (offline) diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index 1600f31..453c1d7 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -10,15 +10,23 @@ Required: - tesseract - tesseract language pack(s) (at least English: `tesseract-data-eng`) - gtk4-layer-shell +- libseccomp, lcms2, fontconfig — linked by the glycin client crate (sandbox + seccomp filter, ICC color management, font paths). Needed at build and link + time even when the runtime falls back to GDK decoding. On Debian/Ubuntu the + build needs `libseccomp-dev`, `liblcms2-dev`, and `libfontconfig-dev`. Optional: - wl-clipboard -- glycin + glycin-gtk4 +- glycin (sandboxed image decoding loaders; detected at runtime — without them + QuickView falls back to in-process GDK decoding. On Debian/Ubuntu the package + is `glycin-loaders`.) ## Rust crates (workspace) - `gtk4` (GTK4 bindings, `v4_10` feature enabled) - `libadwaita` (Adwaita widgets) +- `glycin` (sandboxed image decoding client; always compiled — links + libseccomp, lcms2, and fontconfig) - `gtk4-layer-shell` (Layer Shell integration) - `clap` (CLI) - `tracing` + `tracing-subscriber` (logging) diff --git a/docs/PHASED_PLAN.md b/docs/PHASED_PLAN.md index de8cd0c..f048721 100644 --- a/docs/PHASED_PLAN.md +++ b/docs/PHASED_PLAN.md @@ -144,18 +144,19 @@ If priorities change, you can reshuffle phases, but try to keep the “render fi --- -## Phase 7 — Hardening + performance (not started) +## Phase 7 — Hardening + performance (in progress) **Core tasks** -- Async + sandboxed image loading — implements ADR-0004, fixes NFR-001/NFR-002. - Do this first: the cache DoD below is unverifiable while decode still blocks - the main thread. - - replace the synchronous `Texture::from_file` in `load_file` with glycin's - async loader where available (behind a cargo feature), falling back to GDK - decoding on a background thread via the existing async-channel pattern - - show the previous image or a placeholder until decode completes +- Async + sandboxed image loading ✅ — implements ADR-0004, fixes NFR-001/NFR-002. + - synchronous `Texture::from_file` in `load_file` replaced with glycin's + async loader (always compiled, chosen by a runtime loader probe — no cargo + feature), falling back to GDK decoding on a background thread via the + existing async-channel pattern ✅ + - fallback is session-wide only, never per-file: a file glycin rejects is a + failed load and is never re-fed to the unsandboxed GDK decoder ✅ + - previous image stays visible (under the busy spinner) until decode completes ✅ - stale-result guard (monotonic job ID, same pattern as OCR) so fast - arrow-key navigation cannot race decodes + arrow-key navigation cannot race decodes ✅ - Add cache (in-memory first; `cache.rs` on-disk key derivation exists but is unwired) - Add basic benchmarking hooks (decode + OCR timing) - Improve OCR accuracy options: diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 70b38ff..70489ad 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -13,13 +13,16 @@ depends=( "libadwaita" "tesseract" "gtk4-layer-shell" + # linked by the glycin client crate (sandbox seccomp filter, ICC, fonts) + "libseccomp" + "lcms2" + "fontconfig" ) optdepends=( "tesseract-data-eng: English OCR" "wl-clipboard: CLI clipboard fallback (wl-copy/wl-paste)" - "glycin: sandboxed image decoding (optional feature)" - "glycin-gtk4: glycin → GDK texture conversion" + "glycin: sandboxed image decoding (recommended; runtime-detected, falls back to in-process GDK decoding)" ) makedepends=( diff --git a/packaging/flatpak/com.example.QuickView.json b/packaging/flatpak/com.example.QuickView.json index f334a61..d7c06e6 100644 --- a/packaging/flatpak/com.example.QuickView.json +++ b/packaging/flatpak/com.example.QuickView.json @@ -10,6 +10,26 @@ "--filesystem=host:ro" ], "modules": [ + { + "name": "glycin-loaders", + "buildsystem": "meson", + "config-opts": [ + "-Dglycin-loaders=true", + "-Dglycin-thumbnailer=false", + "-Dlibglycin=false", + "-Dlibglycin-gtk4=false", + "-Dintrospection=false", + "-Dvapi=false", + "-Dtests=false" + ], + "sources": [ + { + "type": "archive", + "url": "https://download.gnome.org/sources/glycin/2.1/glycin-2.1.5.tar.xz", + "sha256": "6c09757ee906330a60b6705753aa56bca007ad219b95e6e3537510d41bc341c8" + } + ] + }, { "name": "quickview", "buildsystem": "simple",