Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
16 changes: 16 additions & 0 deletions adrs/ADR-0004-Image-Decoding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 4 additions & 7 deletions crates/quickview-ui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Declare glycin's native build dependencies

Making glycin unconditional means clean source/package builds now need glycin's native libraries available at build time, before the runtime GDK fallback can ever run. In environments that have the existing GTK/Tesseract setup but not packages such as libseccomp/lcms2/fontconfig development files, cargo build can fail even though the docs and setup paths still present glycin as optional; please either add those native dependencies to the setup/package recipes or keep this behind a feature.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified and fixed in b8e81aacargo tree confirms the glycin client links libseccomp, lcms2, and fontconfig (via libseccomp-sys, lcms2-sys, yeslogic-fontconfig-sys), needed at build/link time regardless of which backend runs. They are now declared in the PKGBUILD depends, the README install line, and DEPENDENCIES.md (with the Debian/Ubuntu -dev package names), and the inaccurate "pure Rust, no system deps" wording was corrected in Cargo.toml and ADR-0004. Keeping the crate unconditional rather than feature-gated: these libraries are present on effectively every GTK-capable system, and a single build recipe avoids per-distro variants.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update CI deps for unconditional glycin

With glycin now always compiled, the clean Arch CI jobs need its native pkg-config dependencies before cargo clippy, cargo test, and cargo build run. I checked both install blocks in .github/workflows/ci.yml; they still install only GTK/Tesseract/layer-shell deps and omit the newly documented libseccomp, lcms2, and fontconfig, so the workflow can fail during dependency build/link before the runtime GDK fallback is ever reached. Fresh evidence beyond the earlier comment is that the CI workflow dependency lists were not updated in this commit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 3b16770 — the three packages are now explicit in both install blocks. Worth noting the premise was partially off: CI passed on the reviewed commit because on Arch gtk4's dependency closure already pulls fontconfig, lcms2, and libseccomp (verified with pactree), and Arch ships headers/.pc files in the main packages. So the workflow could not actually fail as described — but depending on a transitive dep tree for directly-linked libraries is fragile, hence the explicit declaration.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the Arch bootstrap deps

Fresh evidence beyond the earlier dependency thread is that scripts/bootstrap_arch.sh, the Quickstart setup path, still installs only GTK/Tesseract/layer-shell deps and leaves glycin as an optional comment; with glycin now compiled unconditionally here, a clean Arch machine following that script can hit the native link/build requirements before any runtime GDK fallback is possible. Please add the same native packages documented elsewhere (at least the glycin client link deps such as libseccomp) to the bootstrap script too.

Useful? React with 👍 / 👎.

93 changes: 93 additions & 0 deletions crates/quickview-ui/src/decode.rs
Original file line number Diff line number Diff line change
@@ -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<Option<Backend>> = 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<gdk::Texture> {
match backend().await {
Backend::Glycin => decode_glycin(path).await,
Backend::Gdk => decode_gdk(path).await,
}
}

async fn decode_glycin(path: &Path) -> anyhow::Result<gdk::Texture> {
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<gdk::Texture> {
let (sender, receiver) = async_channel::bounded::<Result<gdk::Texture, glib::Error>>(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?)
}
1 change: 1 addition & 0 deletions crates/quickview-ui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use anyhow::Result;

use adw::prelude::*;

mod decode;
pub mod widgets;
pub mod windows;

Expand Down
51 changes: 40 additions & 11 deletions crates/quickview-ui/src/windows/shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ pub struct ViewerController {

ocr_lang: Rc<RefCell<String>>,

// Monotonic id to ignore late OCR results.
// Monotonic ids to ignore late results from superseded jobs.
decode_job_id: Rc<Cell<u64>>,
ocr_job_id: Rc<Cell<u64>>,

on_file_loaded: Rc<RefCell<Option<FileLoadedCallback>>>,
Expand All @@ -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)),
Expand Down Expand Up @@ -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<u64>,
result: anyhow::Result<gtk::gdk::Texture>,
) {
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,
Expand All @@ -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();
Expand All @@ -140,11 +172,8 @@ impl ViewerController {
size_bytes,
load_failed: true,
});
return;
}
}

self.start_ocr(path.to_path_buf());
}

pub fn next_image(&self) {
Expand Down
8 changes: 7 additions & 1 deletion deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
6 changes: 4 additions & 2 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion docs/DEPENDENCIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading