mirror of
https://github.com/edu4rdshl/Strata.git
synced 2026-07-17 23:24:46 +00:00
Add a Theme preference (Automatic / Light / Dark). Automatic follows the
system org.gnome.desktop.interface color-scheme; the dark theme is
unchanged. Light styling lives in light.css, every rule scoped under a
.strata-theme-light class the panel toggles on its root box, loaded into the
St theme context by the extension. St's CSS engine has no custom properties,
so this specificity-based scoped override is how the palette is switched
without generating a stylesheet at runtime; switching is a single class
toggle off the ingest/render hot paths.
Also in this change:
- fix: the keyboard shortcut now hides the panel when it is already open.
The binding lacked Shell.ActionMode.POPUP, so while the panel's modal grab
was active the second press was swallowed and toggle() never ran.
- fix: the search placeholder ("Search...") is now legible in the light
theme; it previously inherited a light-on-dark Shell color.
- pack: bundle light.css via --extra-source (gnome-extensions pack only
auto-includes stylesheet.css), so packaged installs ship the light theme.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
330 lines
13 KiB
Markdown
Executable file
330 lines
13 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 (org.gnome.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 (~256 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 `org.gnome.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
|
|
|
|
```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, ~256 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 shortcuts this path: when the search box is non-empty the panel
|
|
calls `SearchHistory(query, limit)` instead and disables scroll-driven
|
|
appends, so an in-progress search and a scroll event cannot race.
|
|
|
|
### 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. 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 re-loads it on
|
|
theme-context `changed`, since a Shell-theme swap drops dynamically loaded
|
|
sheets); it does nothing until the panel adds the class. 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 `org.gnome.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).
|