Strata/ARCHITECTURE.md
Eduard Tolosa 6f0f7db513 docs: explain the light.css class-toggle mechanism and theme-switch caveat
Clarify how theming actually works: light.css is loaded once but inert until
the panel adds the `.strata-theme-light` class (the more-specific scoped rules
then win); the live light/dark switch is just that class toggle. Document the
single caveat - a full GNOME Shell theme switch replaces the theme object and
drops light.css, so light mode needs a re-enable until then (dark is
unaffected; ordinary/system light-dark switching is fine).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:33:23 -05:00

15 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

  1. Never block the GNOME Shell main loop.
  2. Never lose history. SQLite WAL, atomic upserts, daemon supervisor.
  3. Heavy work lives in Rust; the extension only renders UI and forwards events.
  4. 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-daemon binary is reported up front with a desktop notification; the crash-loop give-up is log-only.)
  • On disable, send Shutdown over D-Bus first, then SIGTERM if 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

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_item before storage. The extension reads the caps from GSettings and pushes them to the daemon via SetConfig, so changes apply at runtime.
  • Thumbnails are decoded once at ingest, stored as PNG. The UI fetches them lazily via GetThumbnail(id) -> ay only for visible rows.
  • History pagination uses the configurable page-size setting (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:

  1. Checks ~/.cache/strata/thumbnails/<id>.png. If present, loads from disk via St.Icon with a file:// URI.
  2. Otherwise calls GetThumbnail(id), which returns the pre-decoded PNG bytes the daemon stored in thumbnail_blob at ingest time. The bytes are written to the cache file, then loaded.
  3. 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. No set_markup, so clipboard content can never inject Pango markup.
  • List updates are batched via GLib.idle_add in 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_text for text or Meta.SelectionSourceMemory.new + set_owner for binary. No code path in Strata executes clipboard content (no spawn, no launch_uri, no show_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 -- e.g. dark .strata-panel { ... } is overridden by .strata-panel.strata-theme-light { ... }. That scoping makes each light rule strictly more specific than its dark counterpart, so it wins deterministically regardless of stylesheet load order.

The switch between themes is a single class. extension.js loads light.css into the St theme context once at enable() (and unloads it on disable()), but loading it changes nothing on screen: its rules are present in the engine yet match no actors, because nothing carries .strata-theme-light yet. The panel resolves the effective theme from the theme setting (auto consults org.gnome.desktop.interface color-scheme) and adds or removes that one class on its root box. With the class present the more-specific light rules win and the panel is light; with it absent only the base dark rules apply. So switching is one class toggle on an existing subtree -- instant, off the ingest/render hot paths, and needing no reload.

light.css is loaded exactly once and the theme context's changed signal is deliberately not used: 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 one caveat of loading once is a full GNOME Shell theme switch (the User Themes extension swapping the whole Shell theme), which replaces the theme object and drops every dynamically loaded sheet, including light.css. After that, light mode falls back to the dark base rules until the extension is re-enabled; dark mode is unaffected because GNOME re-loads stylesheet.css itself. This is rare and recoverable, and far preferable to re-subscribing to changed. The ordinary light/dark switch (including the system Settings light/dark that auto follows) is just the class toggle and is unaffected.

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:

  1. Spawn strata-daemon.
  2. Subscribe to ItemAdded, ItemDeleted, HistoryCleared.
  3. Call GetHistory, SearchHistory, SetClipboard, etc.
  4. On wlroots compositors either let the built-in wl-clipboard-rs monitor handle ingest, or read the clipboard yourself and call SubmitItem(mime, bytes).

See busctl examples in strata-daemon/README.md.