mirror of
https://github.com/edu4rdshl/Strata.git
synced 2026-07-18 07:34:45 +00:00
350 lines
14 KiB
Markdown
Executable file
350 lines
14 KiB
Markdown
Executable file
# Architecture
|
|
|
|
In-depth companion to [`README.md`](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 `await`s anything in the
|
|
hot ingest path (it fires `SubmitItem` and returns immediately).
|
|
|
|
### Daemon supervisor
|
|
|
|
`extension.js` owns the daemon. On enable it `Gio.Subprocess.spawnv`s
|
|
`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.
|
|
|
|
### Single instance
|
|
|
|
Exactly one daemon should serve the bus name. Two layers enforce it: the
|
|
extension skips spawning when `GetNameOwner` shows the name is already owned
|
|
(so a daemon started out-of-band, e.g. via a systemd user service, is reused),
|
|
and the daemon itself requests the name with `DoNotQueue` -- if the name is
|
|
already taken it errors out and exits rather than queueing or stealing it.
|
|
It deliberately does not use `ReplaceExisting`: taking the name from a running
|
|
instance would orphan that instance (zbus does not terminate a replaced owner),
|
|
so the late starter bows out instead.
|
|
|
|
## 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
|
|
|
|
```sql
|
|
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:
|
|
|
|
```rust
|
|
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
|
|
|
|
Light and dark are handled by GNOME Shell's built-in per-variant stylesheet
|
|
loading, not by any code in the extension. When it enables an extension the
|
|
Shell loads `stylesheet-<variant>.css`, where the variant comes from
|
|
`Main.getStyleVariant()` (the shell's own light/dark), and falls back to
|
|
`stylesheet.css`; it reloads that sheet when the color scheme changes.
|
|
|
|
Strata ships two sheets and no `stylesheet.css`:
|
|
|
|
- `stylesheet-dark.css` is the full dark theme (the base).
|
|
- `stylesheet-light.css` does `@import url("stylesheet-dark.css")` and then
|
|
overrides the colors for a light panel. Its rules follow the import, so they
|
|
win by load order.
|
|
|
|
This is the structure the built-in `window-list` extension uses. Because the
|
|
variant tracks the *shell* style, the panel matches the shell chrome: a normal
|
|
session prefers dark, so the panel is dark, and it turns light only when the
|
|
shell itself is light (a `prefer-light` color scheme, the Classic session, or
|
|
high contrast). There is no theme setting and no manual stylesheet loading.
|
|
|
|
## 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`](strata-daemon/README.md).
|