- Search: describe the lazy full-history paged search (snapshot + scroll paging, epoch/results-epoch guards) instead of the old "disables scroll-driven appends" behavior. - Theming: light.css is loaded once with no theme-context 'changed' subscription (removed in 0.7.0 to stop the reload recursion); document the trade-off, not the old re-load-on-changed claim. - Thumbnail size: ~256 px -> ~200 px to match THUMB_PX. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
14 KiB
Executable file
Architecture
In-depth companion to README.md. Covers how Strata is put
together and the trade-offs at each layer.
Design goals
- Never block the GNOME Shell main loop.
- Never lose history. SQLite WAL, atomic upserts, daemon supervisor.
- Heavy work lives in Rust; the extension only renders UI and forwards events.
- Mime allowlist, size caps, password-manager opt-out, no shell exec, no markup parsing of clipboard content.
Topology
+---------------------------- GNOME Shell process (GJS) ---------------------------+
| |
| Meta.Selection --> extension.js --> dbus.js (Gio.DBusProxy) ---+ |
| | |
| panel.js <----------- ui/clipboardItem.js <--------------------+ |
| ^ |
| | ItemAdded / ItemDeleted / HistoryCleared |
+--------+-------------------------------------------------------------------------+
|
| session D-Bus (dev.edu4rdshl.Strata)
v
+----------------------- strata-daemon (Rust process) --------------------------+
| |
| zbus interface --> tokio executor --> spawn_blocking --> rusqlite (Mutex) |
| | | |
| | v |
| | +---------------------+ |
| | | SQLite (WAL, FTS5) | |
| | +---------------------+ |
| v |
| image::load_from_memory --> PNG thumbnail (~200 px) |
| |
+-------------------------------------------------------------------------------+
Process model
Separate daemon
GJS is single-threaded and shares its main loop with the entire GNOME Shell
compositor. Any synchronous syscall, hash computation, or SQLite query in
JS can freeze the desktop. Strata moves all of it across a process
boundary. The cost is one IPC hop per operation; D-Bus is shared-memory
fast on the same machine, and the extension never awaits anything in the
hot ingest path (it fires SubmitItem and returns immediately).
Daemon supervisor
extension.js owns the daemon. On enable it Gio.Subprocess.spawnvs
bin/strata-daemon and registers a watchdog:
- If the child exits, schedule a respawn with exponential backoff (1 s, 2 s, 4 s, 8 s, 16 s). A run that lasts at least 5 s resets the counter, so only a rapid crash loop escalates.
- After 5 rapid restarts, stop retrying and log an error. (A missing
strata-daemonbinary is reported up front with a desktop notification; the crash-loop give-up is log-only.) - On disable, send
Shutdownover D-Bus first, thenSIGTERMif it doesn't exit within 1.5 s.
Startup
The extension listens for notify::g-name-owner on the D-Bus proxy. When
the daemon's dev.edu4rdshl.Strata name becomes owned, the panel triggers
its initial fetch. No polling, no fixed delays.
Ingest path
Meta.Selection.OwnerChanged
|
+-- _readClipboard()
|
+-- _pickMime() enforces strict allowlist
+-- skip if x-kde-passwordManagerHint present
+-- Meta.Selection.transfer_async() --> Uint8Array
+-- SubmitItemAsync(mime, rawBytes) (D-Bus 'ay')
|
v
daemon::dbus_service::submit_item(mime, Vec<u8>)
|
+-- spawn_blocking(move || {
hash = blake3(bytes)
upsert by content_hash (returns existing id or new)
on new image: decode + thumbnail
emit ItemAdded
prune to max_history
+-- emit ItemDeleted per pruned id
})
Content hash
blake3 for dedup. A unique index on content_hash makes the upsert
atomic: copying the same content twice updates created_at instead of
creating a duplicate row.
Wire format
SubmitItem takes ay (D-Bus byte array) directly. No base64 encoding
in JS, no decode step in Rust. Likewise GetItemContent returns (s, ay) --
mime type plus raw bytes -- so paste-back requires no base64 decode on the
compositor thread either.
Mime allowlist
Meta.Selection.transfer_async reads the full clipboard payload into GJS
memory before any size check is possible, so the only safe defence
against a hostile or buggy app putting a 1 GB blob on the clipboard is to
refuse mime types we don't recognise. The list lives in
extension.js::_pickMime and daemon::main::pick_mime and covers the
common text and image types. Password-manager hint mimes
(x-kde-passwordManagerHint) are skipped on both paths.
Storage
Schema
CREATE TABLE clipboard_history (
id TEXT PRIMARY KEY, -- UUID v4
mime_type TEXT NOT NULL,
content_text TEXT, -- one of these two is populated
content_blob BLOB, -- (text vs binary)
thumbnail_blob BLOB, -- pre-decoded PNG, ~200 px
content_hash TEXT NOT NULL, -- blake3 of raw bytes
source_app TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_created_at ON clipboard_history (created_at DESC);
CREATE UNIQUE INDEX idx_hash ON clipboard_history (content_hash);
CREATE VIRTUAL TABLE clipboard_fts USING fts5(
content_text,
content='clipboard_history',
content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
);
-- plus AI / AD / AU triggers keeping FTS in sync with the base table.
PRAGMAs
journal_mode = WAL -- readers don't block the writer
synchronous = NORMAL -- fsync on commit, not on every write
foreign_keys = ON
FTS5 search
Full-text index over content_text only. Images and other binaries are
not indexed; an empty search shows everything, a non-empty search shows
only matching text items.
tokenize='unicode61 remove_diacritics 2' gives O(log n) prefix search
and matches across diacritics (searching cafe finds café).
The FTS5 table uses content='clipboard_history' (external content), so
text is stored once in the base table and FTS5 holds only the inverted
index.
FTS5 query construction
User input is split into whitespace tokens. Each token has embedded "
doubled (FTS5 escape) and is wrapped in "..." with a * prefix
suffix:
input: cafe hello
tokens: ["cafe", "hello"]
FTS5: "cafe"* "hello"*
The constructed string is bound as a parameter value (?N), not
interpolated into SQL. A malformed FTS5 escape can only affect the
search query, not the SQL parser.
Pruning
After a genuinely new item is inserted, the ingest task calls prune,
which deletes every row outside the newest max_history (by created_at)
and returns their ids. The D-Bus layer emits ItemDeleted for each so the
extension can unlink the matching ~/.cache/strata/thumbnails/<id>.png.
Concurrency
The Rust daemon runs on a multi-threaded tokio runtime (#[tokio::main],
which defaults to the multi-thread flavor). zbus dispatches each incoming
method call on the executor. rusqlite is sync,
so every DB call is wrapped:
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.lock(); // poison-recovering wrapper
db::upsert_item(&guard, ...)
}).await?
The D-Bus reactor stays responsive while disk I/O runs on the blocking pool. One mutex serialises writers (SQLite is single-writer in WAL). The lock wrapper takes the inner value even if a previous holder panicked, so a single bad task can't poison the global state.
Memory bounds
- Mime allowlist gates which payloads are even read.
- Text and image size caps are user-configurable (defaults 1 MB and 5 MB).
Payloads larger than the configured cap are rejected at
submit_itembefore storage. The extension reads the caps from GSettings and pushes them to the daemon viaSetConfig, so changes apply at runtime. - Thumbnails are decoded once at ingest, stored as PNG. The UI fetches
them lazily via
GetThumbnail(id) -> ayonly for visible rows. - History pagination uses the configurable
page-sizesetting (default 50). The panel loads one page on open and one more each time the scroll reaches the bottom. The full table never sits in JS memory.
Lazy loading
Two independent lazy layers keep the panel responsive regardless of history size.
Paginated history
GetHistory(offset, limit) returns metadata only (id, mime, short text
preview, timestamp). The preview is truncated in SQL (substr, first
~200 chars), so a page of large text items costs a few KB of JSON rather
than megabytes; the full payload is only fetched on paste-back via
GetItemContent. The panel loads page-size rows on open, then another
page each time the scroll position comes within a fixed threshold (200 px)
of the bottom. The Rust side serves these from the idx_created_at DESC
index with LIMIT/OFFSET, which stays O(log n) for any history size.
Search uses a parallel path: when the search box is non-empty the panel
calls SearchHistory(query, max-history), which returns the full match set
(bounded by the configured history size, not an arbitrary cap). The panel
snapshots those results and renders them lazily, a page at a time as you
scroll -- the same paging mechanism as the recent view, but fed from the
in-memory snapshot instead of re-querying. A search epoch plus a
results-epoch guard discard stale renders, so a fast new query (or a scroll
landing during a query's fetch) can never paint the previous query's rows.
On-demand thumbnails
GetHistory does not return image bytes. For each image row,
ui/clipboardItem.js builds the row with a placeholder icon and then:
- Checks
~/.cache/strata/thumbnails/<id>.png. If present, loads from disk viaSt.Iconwith afile://URI. - Otherwise calls
GetThumbnail(id), which returns the pre-decoded PNG bytes the daemon stored inthumbnail_blobat ingest time. The bytes are written to the cache file, then loaded. - On
ItemDeleted(including prune-driven deletes), the cache file is unlinked.
Effect: scrolling past 1000 image rows costs zero D-Bus traffic for the rows above and below the viewport. Each thumbnail is fetched at most once per process lifetime; reopens after the first fetch read the PNG straight from the page cache.
The daemon does the expensive part (image decode + resize) exactly once, at ingest, on the blocking pool. The UI never decodes a full-resolution image.
UI invariants
St.Label({ text: ... })only. Noset_markup, so clipboard content can never inject Pango markup.- List updates are batched via
GLib.idle_addin chunks of 20. - Search has a 150 ms debounce and an epoch counter; stale responses that arrive after a newer query are dropped.
- Paste-back uses
St.Clipboard.set_textfor text orMeta.SelectionSourceMemory.new+set_ownerfor binary. No code path in Strata executes clipboard content (nospawn, nolaunch_uri, noshow_uri).
Theming
St's CSS engine has no custom properties (var()) and no reliable
!important, so the light theme is not a runtime-generated stylesheet.
Instead stylesheet.css is the dark theme (default, auto-loaded by GNOME),
and light.css carries light overrides with every rule scoped under a
.strata-theme-light ancestor class. That scoping makes each light rule
strictly more specific than its dark counterpart, so it wins
deterministically regardless of stylesheet load order. extension.js loads
light.css into the St theme context once at enable() and unloads it on
disable(); it does nothing until the panel adds the class. It deliberately
does NOT subscribe to the theme context's changed signal: load_stylesheet
itself emits changed, so reloading on it feeds back into itself and hits
"too much recursion" (it fired on screen unlock, which restyles widgets). The
trade-off of the one-time load is that a GNOME Shell theme switch drops the
sheet until the extension is re-enabled. The panel resolves the effective
theme from the theme setting (auto consults
org.gnome.desktop.interface color-scheme) and toggles the class on its
root box. Switching themes is one class toggle on an existing subtree, off
the ingest/render hot paths.
Wayland clipboard monitor
The daemon contains a wl-clipboard-rs monitor for ext-data-control-v1
and zwlr-data-control-v1. GNOME's Mutter does not expose either, so on
GNOME the monitor logs INFO and all ingest comes from GJS via
Meta.Selection + SubmitItem.
The monitor is kept because it makes the daemon usable standalone on wlroots-based compositors (Sway, Hyprland) with a non-GNOME front-end, and it is small and isolated.
Security boundary
| Boundary | Threat | Mitigation |
|---|---|---|
| App to clipboard | Huge blob OOMs Shell | Mime allowlist, size caps |
| App to history | Password leak | x-kde-passwordManagerHint opt-out |
| User to search | SQL injection | rusqlite params![], FTS5 input bound as value |
| Daemon to FS | Path traversal via id | Ids are server-generated UUID v4 |
| Daemon to extension | Signal spoofing | D-Bus enforces single owner of dev.edu4rdshl.Strata |
| Stored item to paste | Command execution | No spawn, no launch_uri, no markup parsing |
Non-GNOME front-end
The daemon's D-Bus interface is the contract:
- Spawn
strata-daemon. - Subscribe to
ItemAdded,ItemDeleted,HistoryCleared. - Call
GetHistory,SearchHistory,SetClipboard, etc. - On wlroots compositors either let the built-in
wl-clipboard-rsmonitor handle ingest, or read the clipboard yourself and callSubmitItem(mime, bytes).
See busctl examples in strata-daemon/README.md.