Initial commit

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard Tolosa 2026-05-25 03:02:34 -05:00
commit 350d647953
27 changed files with 6694 additions and 0 deletions

63
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,63 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
jobs:
daemon:
name: Rust daemon
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update -q
sudo apt-get install -y libdbus-1-dev pkg-config
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
strata-daemon/target
key: ${{ runner.os }}-cargo-${{ hashFiles('strata-daemon/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Check formatting
run: cargo fmt --manifest-path strata-daemon/Cargo.toml -- --check
- name: Build
run: cargo build --release --manifest-path strata-daemon/Cargo.toml
- name: Clippy
run: cargo clippy --manifest-path strata-daemon/Cargo.toml -- -W clippy::all
- name: Test
run: cargo test --manifest-path strata-daemon/Cargo.toml
extension:
name: GNOME extension
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install glib-compile-schemas
run: sudo apt-get update -q && sudo apt-get install -y libglib2.0-bin
- name: Compile GSettings schema
run: glib-compile-schemas strata@edu4rdshl.dev/schemas/

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
# Rust build artifacts
strata-daemon/target/
# Bundled binary (no longer shipped - daemon must be installed separately)
strata@edu4rdshl.dev/bin/
# Screenshots / scratch images
*.png
# External reference code (not part of this project)
external_sources/
# Editor / OS
.vscode/
.idea/
.DS_Store
**/gschemas.compiled

99
AGENTS.md Normal file
View file

@ -0,0 +1,99 @@
# AGENTS.md
Guidelines for AI agents working on this codebase.
## Repository layout
```
strata-daemon/ Rust daemon (tokio, zbus, rusqlite)
strata@edu4rdshl.dev/ GNOME Shell extension (GJS) - pure JS, no binary
schemas/ GSettings schema XML
ui/ Panel and item widgets
contrib/systemd/ systemd user service unit for distro packaging
ARCHITECTURE.md In-depth design document
```
## Core principle
"JS draws, Rust thinks." The extension must never block the GNOME Shell
compositor. All heavy work (hashing, storage, search, thumbnails) lives in
the daemon. The extension only renders and forwards events.
## Build
```bash
# Build daemon + compile schemas
make
# Install daemon binary to ~/.local/bin (must be in PATH)
make install-daemon
# Install extension to ~/.local/share/gnome-shell/extensions/
make install
```
## Lint
```bash
cargo clippy --manifest-path=strata-daemon/Cargo.toml
```
No linter is configured for GJS. Follow the existing code style.
## Deploy to a test VM
```bash
# Build and copy daemon binary to VM
cargo build --release --manifest-path=strata-daemon/Cargo.toml
scp strata-daemon/target/release/strata-daemon fedoradev:~/.local/bin/
# Sync extension (JS only)
rsync -a strata@edu4rdshl.dev/ fedoradev:~/.local/share/gnome-shell/extensions/strata@edu4rdshl.dev/ --exclude='.git'
ssh fedoradev 'glib-compile-schemas ~/.local/share/gnome-shell/extensions/strata@edu4rdshl.dev/schemas/'
# Then log out and back in (Wayland) or Alt+F2 r (X11)
```
## Key files
| File | Purpose |
|---|---|
| `strata-daemon/src/main.rs` | Entry point, Wayland monitor, GJS submit task, `process_change`/`process_bytes` |
| `strata-daemon/src/dbus_service.rs` | D-Bus interface, `Limits` struct, all D-Bus methods |
| `strata-daemon/src/db.rs` | SQLite schema, FTS5, insert, prune, search, thumbnail |
| `strata-daemon/src/config.rs` | Data dir, DB path (`~/.local/share/strata/clipboard.db`) |
| `strata@edu4rdshl.dev/extension.js` | Daemon lifecycle (PATH lookup + systemd detection), clipboard ingest, focus tracking, `_pushConfig` |
| `strata@edu4rdshl.dev/ui/panel.js` | Popup panel, lazy-load pagination, search, `_pageSize` |
| `strata@edu4rdshl.dev/ui/clipboardItem.js` | Individual row widget |
| `strata@edu4rdshl.dev/prefs.js` | Preferences window (Adw) |
| `strata@edu4rdshl.dev/dbus.js` | D-Bus proxy definition and XML interface |
| `contrib/systemd/strata-daemon.service` | systemd user unit for distro packaging |
## D-Bus interface summary
Service `org.gnome.Strata`, object `/org/gnome/Strata`, interface `org.gnome.Strata.Manager`.
Methods: `GetHistory(offset u32, limit u32) -> json s`,
`SearchHistory(query s, limit u32) -> json s`,
`GetThumbnail(id s) -> png_bytes ay`,
`GetItemContent(id s) -> (mime_type s, content_b64 s)`,
`SetClipboard(id s)`, `DeleteItem(id s)`, `ClearHistory()`,
`SetConfig(max_history u32, max_text_bytes u32, max_image_bytes u32)`,
`SubmitItem(mime s, data ay)`, `SetFocusedApp(app_id s)`, `Shutdown()`.
Signals: `ItemAdded(id s, mime_type s, preview s)`, `ItemDeleted(id s)`, `HistoryCleared()`.
## GSettings keys
`max-history`, `page-size`, `max-text-mb`, `max-image-mb`, `panel-position`,
`panel-width`, `panel-max-height`, `keyboard-shortcut`, `move-activated-to-top`,
`excluded-apps`.
## Conventions
- No `--` em-dashes, no emojis in code comments or docs.
- Rust: use `tracing::info!`, `tracing::warn!`, `tracing::error!` (qualified path, not bare imports). No `println!` in daemon code.
- GJS: `console.log('[Strata] ...')` / `console.error('[Strata] ...')` prefix for all extension logs.
- Size limits travel as bytes over D-Bus. MB conversion happens extension-side.
- `Ordering::Relaxed` is intentional on `Arc<AtomicUsize>` limits (advisory, not critical path).
- Never execute clipboard content. Writes go through `wl-clipboard-rs` (`copy_multi`) in the daemon, never via shell subprocess or `eval`.
- Excluded apps list is checked before storing any clipboard item.

304
ARCHITECTURE.md Executable file
View file

@ -0,0 +1,304 @@
# 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, capped at 30 s).
- After 5 rapid restarts within 60 s, stop and surface a notification.
- On disable, send `Shutdown` over D-Bus first, then `SIGTERM` if it
doesn't exit within 2 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.
### 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
`upsert_item` checks `COUNT(*) > max_history` after each insert and
deletes the oldest excess rows by `created_at ASC`, returning 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 `tokio::main(flavor = "multi_thread")`. 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 panel loads `page-size` rows on open, then
another page each time the scroll position passes ~80 % of the viewport.
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`).
## 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).

40
CHANGELOG.md Normal file
View file

@ -0,0 +1,40 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
---
## [0.2.0] - 2026-05-25
### Fixed
- **MIME priority: plain text now preferred over rich text.**
When an application (code editors, web browsers, etc.) advertises both a
plain-text and a rich-text (`text/html`, `text/rtf`) variant on the clipboard,
Strata now captures the plain-text variant. Previously, `text/html` ranked
above `text/plain`, causing syntax-highlighted HTML markup to be stored
instead of the actual source text when copying from code editors.
Rich-text types are still captured when no plain-text alternative is offered.
Fix applied to both the Rust daemon (`pick_mime`) and the GNOME Shell
extension (`_pickMime`) to keep them in sync.
---
## [0.1.0] - 2026-05-24
### Added
- Initial release.
- Rust daemon (`strata-daemon`) with SQLite/FTS5 storage, Wayland clipboard
monitor, D-Bus interface (`org.gnome.Strata`), and image thumbnail support.
- GNOME Shell extension (`strata@edu4rdshl.dev`) with popup panel, lazy-loaded
pagination, full-text search, image thumbnails, and paste-back.
- Preferences window (Adwaita) for history size, size limits, keyboard
shortcut, panel position/width, and excluded-app list.
- Daemon lifecycle management: extension detects an existing systemd-managed
daemon and falls back to spawning from `PATH`; shows a desktop notification
when the binary is not found.
- `contrib/systemd/strata-daemon.service` for distribution packaging.
- `Makefile` with `install-daemon`, `install`, and `pack` targets.

46
Makefile Normal file
View file

@ -0,0 +1,46 @@
EXTENSION_UUID = strata@edu4rdshl.dev
DAEMON_RELEASE = strata-daemon/target/release/strata-daemon
SCHEMA_DIR = $(EXTENSION_UUID)/schemas
INSTALL_DIR = $(HOME)/.local/share/gnome-shell/extensions/$(EXTENSION_UUID)
DAEMON_INSTALL = $(HOME)/.local/bin
.PHONY: all daemon schemas install install-daemon pack clean
all: daemon schemas
daemon:
cargo build --release --manifest-path=strata-daemon/Cargo.toml
schemas:
glib-compile-schemas $(SCHEMA_DIR)
# Install the daemon binary to ~/.local/bin (must be in PATH).
install-daemon: daemon
mkdir -p $(DAEMON_INSTALL)
cp $(DAEMON_RELEASE) $(DAEMON_INSTALL)/strata-daemon
@echo "Installed daemon to $(DAEMON_INSTALL)/strata-daemon"
@echo "Make sure $(DAEMON_INSTALL) is in your PATH."
# Install the GNOME Shell extension only (daemon must already be in PATH).
install: schemas
mkdir -p $(INSTALL_DIR)/schemas $(INSTALL_DIR)/ui
cp $(EXTENSION_UUID)/schemas/*.gschema.xml $(INSTALL_DIR)/schemas/
cp $(SCHEMA_DIR)/gschemas.compiled $(INSTALL_DIR)/schemas/
cp $(EXTENSION_UUID)/*.js $(INSTALL_DIR)/
cp $(EXTENSION_UUID)/*.css $(INSTALL_DIR)/
cp $(EXTENSION_UUID)/metadata.json $(INSTALL_DIR)/
cp $(EXTENSION_UUID)/ui/*.js $(INSTALL_DIR)/ui/
@echo "Installed to $(INSTALL_DIR)"
@echo "Run: gnome-extensions enable $(EXTENSION_UUID)"
# Pack the extension (JS only - no binary).
pack: schemas
gnome-extensions pack $(EXTENSION_UUID) \
--extra-source=ui \
--force
@echo "Packed: $(EXTENSION_UUID).shell-extension.zip"
clean:
cargo clean --manifest-path=strata-daemon/Cargo.toml
rm -f $(SCHEMA_DIR)/gschemas.compiled
rm -f $(EXTENSION_UUID).shell-extension.zip

111
README.md Normal file
View file

@ -0,0 +1,111 @@
# Strata
A fast, stutter-free clipboard manager for GNOME Shell.
All heavy work (hashing, decoding, storage, search, thumbnails) lives in a Rust
daemon. The GNOME Shell extension only renders UI and forwards events over
D-Bus, so the compositor is never blocked, even with thousands of items.
## Architecture
Strata is **two components**, and you need **both** for it to work:
| Component | Language | Role |
|---|---|---|
| [`strata-daemon/`](strata-daemon/) | Rust + tokio + zbus | Storage (SQLite + FTS5), dedup, thumbnails, D-Bus service `org.gnome.Strata` |
| [`strata@edu4rdshl.dev/`](strata@edu4rdshl.dev/) | GJS (GNOME Shell extension) | Top-bar panel, search UI, paste-back, clipboard ingest |
The extension auto-connects to the daemon on enable. If the daemon is managed
by systemd (or another init system), the extension detects it and skips
spawning its own copy. If no daemon is running, the extension looks for
`strata-daemon` in `$PATH` and spawns it directly.
```
GNOME Shell (GJS) ──D-Bus──▶ strata-daemon ──▶ SQLite (~/.local/share/strata)
└──▶ thumbnails (~/.cache/strata)
```
## Requirements
- GNOME Shell 50 (tested). May work on 4549 but untested — if you try it and it works, please open an issue to let us know.
- `strata-daemon` binary in `$PATH` (see Install below)
- Rust 1.74+ (build only)
- `glib-compile-schemas` (from `glib2-devel` / `libglib2.0-dev-bin`)
- SQLite is bundled via `rusqlite`, no system dep needed
## Install
### From source (local build)
```sh
git clone https://github.com/Edu4rdSHL/Strata.git
cd Strata
# Build and install the daemon binary to ~/.local/bin
make install-daemon
# Install the GNOME Shell extension
make install
```
Make sure `~/.local/bin` is in your `$PATH`, then log out / log back in
(Wayland) or `Alt+F2``r` (X11) and enable:
```sh
gnome-extensions enable strata@edu4rdshl.dev
```
### Via systemd user service (distro packages / manual)
Distro packages install the daemon binary to `/usr/bin/strata-daemon` and
the systemd unit from `contrib/systemd/strata-daemon.service` to
`/usr/lib/systemd/user/`. Enable it once:
```sh
systemctl --user enable --now strata-daemon
```
Then install and enable the extension as above. The extension detects the
running daemon and will not spawn a second copy.
### Pack for extensions.gnome.org
```sh
make pack # produces strata@edu4rdshl.dev.shell-extension.zip (JS only)
```
## Uninstall
```sh
gnome-extensions disable strata@edu4rdshl.dev
rm -rf ~/.local/share/gnome-shell/extensions/strata@edu4rdshl.dev
rm -rf ~/.local/share/strata ~/.cache/strata # also wipes history
```
## Other desktops?
The daemon is desktop-agnostic. It speaks plain D-Bus and runs anywhere
the session bus does. Any client (KDE applet, CLI tool, your own script)
can drive it. See [`strata-daemon/README.md`](strata-daemon/README.md) for
the wire protocol and a `busctl` example.
The shipped UI is a GNOME Shell extension. Ports to other desktops only
need a new front-end against the same D-Bus interface.
## Deeper reading
- [`ARCHITECTURE.md`](ARCHITECTURE.md): design goals, process model,
storage schema, FTS5 details, concurrency, security boundary.
- [`strata-daemon/README.md`](strata-daemon/README.md): D-Bus interface
reference and standalone usage.
- [`strata@edu4rdshl.dev/README.md`](strata@edu4rdshl.dev/README.md):
extension internals.
## AI Policy Disclosure
Parts of this codebase and documentation were written with the assistance of AI tools. This is the policy we follow and will continue to follow: every line of code and every document produced with AI assistance is rigorously reviewed by a human before being published. No AI-generated output is committed without understanding, verification, and approval by the project author.
## License
GPL-3.0-or-later.

View file

@ -0,0 +1,14 @@
[Unit]
Description=Strata clipboard manager daemon
Documentation=https://github.com/Edu4rdSHL/Strata
# Start after the D-Bus session bus and the graphical session are available.
After=graphical-session.target
PartOf=graphical-session.target
[Service]
ExecStart=/usr/bin/strata-daemon
Restart=on-failure
RestartSec=5
[Install]
WantedBy=graphical-session.target

2170
strata-daemon/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

54
strata-daemon/Cargo.toml Normal file
View file

@ -0,0 +1,54 @@
[package]
name = "strata-daemon"
version = "0.2.0"
edition = "2021"
description = "Strata clipboard manager backend daemon"
[[bin]]
name = "strata-daemon"
path = "src/main.rs"
[dependencies]
# Async runtime
tokio = { version = "1", features = ["full"] }
# D-Bus (async, tokio-native)
zbus = { version = "4", features = ["tokio"] }
# Wayland clipboard monitoring (custom event loop)
wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
# Clipboard read / write (paste::get_contents + copy::copy_multi)
# Supports ext-data-control-v1 (GNOME 47+) and zwlr-data-control-v1 fallback
wl-clipboard-rs = "0.9"
# Storage
rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
# Image thumbnail generation (runs in spawn_blocking — never blocks async runtime)
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
base64 = { version = "0.22", features = ["std"] }
# Fast content hashing for deduplication
blake3 = "1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# XDG paths
dirs = "5"
# Error handling
anyhow = "1"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true

101
strata-daemon/README.md Normal file
View file

@ -0,0 +1,101 @@
# strata-daemon
The Rust backend for [Strata](../README.md). Speaks D-Bus, stores history in
SQLite, generates image thumbnails, and indexes text for full-text search.
It is **not GNOME-specific**. Any D-Bus client can use it.
## Build
```sh
cargo build --release
# binary at target/release/strata-daemon
```
The GNOME extension spawns this binary automatically; you only need to run it
manually if you're using a non-GNOME front-end.
## Storage
| Path | Contents |
|---|---|
| `~/.local/share/strata/clipboard.db` | History DB (WAL mode, FTS5 index) |
| `~/.cache/strata/thumbnails/<id>.png` | Per-item image thumbnails |
## D-Bus interface
- **Bus name:** `org.gnome.Strata`
- **Object path:** `/org/gnome/Strata`
- **Interface:** `org.gnome.Strata.Manager`
| Member | Signature | Notes |
|---|---|---|
| `SubmitItem` (method) | `(s ay) → ()` | Ingest a clipboard payload (mime, raw bytes) |
| `GetHistory` (method) | `(u u) → s` | `(offset, limit)` → JSON array |
| `SearchHistory` (method) | `(s u) → s` | FTS5 query → JSON array |
| `GetThumbnail` (method) | `s → ay` | PNG bytes for item id |
| `GetItemContent` (method) | `s → (ss)` | `(mime, base64)` for paste-back |
| `SetClipboard` (method) | `s → ()` | Re-copy a stored item |
| `DeleteItem` (method) | `s → ()` | |
| `ClearHistory` (method) | `() → ()` | |
| `Shutdown` (method) | `() → ()` | Graceful exit |
| `SetConfig` (method) | `(uuu) → ()` | `(max_history, max_text_bytes, max_image_bytes)`; 0 means leave unchanged |
| `ItemAdded` (signal) | `(sss)` | `(id, mime, preview)` |
| `ItemDeleted` (signal) | `s` | |
| `HistoryCleared` (signal) | `()` | |
### Example: drive it from the shell
```sh
# Start the daemon (foreground)
./target/release/strata-daemon
# In another terminal:
busctl --user call org.gnome.Strata /org/gnome/Strata \
org.gnome.Strata.Manager GetHistory uu 0 10
# Submit text:
busctl --user call org.gnome.Strata /org/gnome/Strata \
org.gnome.Strata.Manager SubmitItem say "text/plain;charset=utf-8" 5 104 101 108 108 111
# Listen for new items:
busctl --user monitor org.gnome.Strata
```
## Supported clipboard payloads
Strict mime allowlist. Per-type size caps default to 1 MB for text and
5 MB for images; both are runtime-configurable via the `SetConfig`
D-Bus method (the GNOME extension wires this to its GSettings).
- Text: `text/plain*`, `UTF8_STRING`, `STRING`, `TEXT`, `text/rtf`,
`text/markdown`, `text/html`, `text/uri-list`, `x-special/nautilus-clipboard`,
`application/x-kde-cutselection`, `application/rtf`
- Images: `image/png`, `image/jpeg`, `image/jpg`, `image/gif`, `image/webp`,
`image/bmp`, `image/tiff`, `image/avif`, `image/x-icon`, `image/svg+xml`
Items carrying `x-kde-passwordManagerHint` are silently dropped
(KeePassXC / Bitwarden convention).
## Module map
```
src/
├── main.rs Entry point, clipboard ingest, mime allowlist
├── config.rs Config loading + size caps
├── db.rs SQLite schema, FTS5, parameterised queries
├── dbus_service.rs zbus interface implementation
└── clipboard/
├── monitor.rs Optional wl-clipboard-rs monitor (unused on GNOME)
└── writer.rs Paste-back helpers
```
## Tests
```sh
cargo test --release
```
## License
GPL-3.0-or-later.

View file

@ -0,0 +1,2 @@
pub mod monitor;
pub mod writer;

View file

@ -0,0 +1,315 @@
/// Wayland clipboard monitor using the ext-data-control-v1 protocol (GNOME 47+)
/// with a zwlr-data-control-v1 fallback for wlroots-based compositors.
///
/// Runs on a dedicated OS thread (NOT on the tokio runtime) to avoid blocking
/// the async executor. Communicates clipboard change events to the tokio world
/// via an `mpsc::UnboundedSender`.
///
/// Protocol reference: staging/ext-data-control/ext-data-control-v1.xml
use std::collections::HashMap;
use anyhow::{bail, Result};
use tokio::sync::mpsc::UnboundedSender;
use wayland_client::{
backend::ObjectId,
globals::{registry_queue_init, GlobalListContents},
protocol::{wl_registry, wl_seat},
Connection, Dispatch, Proxy, QueueHandle,
};
use wayland_protocols::ext::data_control::v1::client::{
ext_data_control_device_v1::{self, ExtDataControlDeviceV1},
ext_data_control_manager_v1::{self, ExtDataControlManagerV1},
ext_data_control_offer_v1::{self, ExtDataControlOfferV1},
};
use wayland_protocols_wlr::data_control::v1::client::{
zwlr_data_control_device_v1::{self, ZwlrDataControlDeviceV1},
zwlr_data_control_manager_v1::{self, ZwlrDataControlManagerV1},
zwlr_data_control_offer_v1::{self, ZwlrDataControlOfferV1},
};
/// A clipboard change notification: the compositor has set a new selection.
/// `mime_types` lists all MIME types the new selection provides.
#[derive(Debug)]
pub struct ClipboardChange {
pub mime_types: Vec<String>,
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
struct MonitorState {
/// Pending offers keyed by Wayland object ID.
/// Each entry holds the offered MIME types as they arrive.
offers: HashMap<ObjectId, Vec<String>>,
tx: UnboundedSender<ClipboardChange>,
}
impl MonitorState {
fn new(tx: UnboundedSender<ClipboardChange>) -> Self {
Self {
offers: HashMap::new(),
tx,
}
}
fn record_offer_mime(&mut self, id: ObjectId, mime: String) {
self.offers.entry(id).or_default().push(mime);
}
fn commit_selection(&mut self, offer_id: ObjectId) {
// Take the mime list for this offer (and discard stale in-flight offers).
let mimes = self.offers.remove(&offer_id).unwrap_or_default();
self.offers.clear(); // discard any superseded offers
if !mimes.is_empty() {
let _ = self.tx.send(ClipboardChange { mime_types: mimes });
}
}
}
// ---------------------------------------------------------------------------
// WlRegistry - required by registry_queue_init
// ---------------------------------------------------------------------------
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for MonitorState {
fn event(
_state: &mut Self,
_proxy: &wl_registry::WlRegistry,
_event: wl_registry::Event,
_data: &GlobalListContents,
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
// Handled by GlobalList inside registry_queue_init.
}
}
// ---------------------------------------------------------------------------
// ext-data-control-v1
// ---------------------------------------------------------------------------
impl Dispatch<ExtDataControlManagerV1, ()> for MonitorState {
fn event(
_: &mut Self,
_: &ExtDataControlManagerV1,
_: ext_data_control_manager_v1::Event, // manager has no events - unreachable
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<ExtDataControlDeviceV1, ()> for MonitorState {
fn event(
state: &mut Self,
_device: &ExtDataControlDeviceV1,
event: ext_data_control_device_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
ext_data_control_device_v1::Event::DataOffer { id } => {
// Register the new offer object so subsequent Offer events can
// find it.
state.offers.insert(id.id(), Vec::new());
}
ext_data_control_device_v1::Event::Selection { id: Some(offer) } => {
let oid = offer.id();
state.commit_selection(oid);
// Politely destroy the offer - the compositor will clean up
// the Wayland object.
offer.destroy();
}
ext_data_control_device_v1::Event::Selection { id: None } => {
// null id means "clipboard cleared" - ignore.
}
ext_data_control_device_v1::Event::Finished => {
tracing::warn!("ext-data-control device finished (compositor revoked access)");
}
_ => {}
}
}
}
impl Dispatch<ExtDataControlOfferV1, ()> for MonitorState {
fn event(
state: &mut Self,
proxy: &ExtDataControlOfferV1,
event: ext_data_control_offer_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let ext_data_control_offer_v1::Event::Offer { mime_type } = event {
state.record_offer_mime(proxy.id(), mime_type);
}
}
}
// ---------------------------------------------------------------------------
// zwlr-data-control-v1 (fallback for wlroots compositors)
// ---------------------------------------------------------------------------
impl Dispatch<ZwlrDataControlManagerV1, ()> for MonitorState {
fn event(
_: &mut Self,
_: &ZwlrDataControlManagerV1,
_: zwlr_data_control_manager_v1::Event, // manager has no events - unreachable
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<ZwlrDataControlDeviceV1, ()> for MonitorState {
fn event(
state: &mut Self,
_device: &ZwlrDataControlDeviceV1,
event: zwlr_data_control_device_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
zwlr_data_control_device_v1::Event::DataOffer { id } => {
state.offers.insert(id.id(), Vec::new());
}
zwlr_data_control_device_v1::Event::Selection { id: Some(offer) } => {
let oid = offer.id();
state.commit_selection(oid);
offer.destroy();
}
zwlr_data_control_device_v1::Event::Selection { id: None } => {}
zwlr_data_control_device_v1::Event::Finished => {
tracing::warn!("zwlr-data-control device finished");
}
_ => {}
}
}
}
impl Dispatch<ZwlrDataControlOfferV1, ()> for MonitorState {
fn event(
state: &mut Self,
proxy: &ZwlrDataControlOfferV1,
event: zwlr_data_control_offer_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let zwlr_data_control_offer_v1::Event::Offer { mime_type } = event {
state.record_offer_mime(proxy.id(), mime_type);
}
}
}
// wl_seat - needed for binding, no events we care about.
wayland_client::delegate_noop!(MonitorState: ignore wl_seat::WlSeat);
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Spawn the clipboard monitor on a dedicated OS thread.
/// Returns immediately; the thread sends `ClipboardChange` events via `tx`.
pub fn spawn(tx: UnboundedSender<ClipboardChange>) -> Result<()> {
let conn = Connection::connect_to_env()
.map_err(|e| anyhow::anyhow!("Could not connect to Wayland display: {e}"))?;
// Probe available protocols with a throwaway state.
let protocol = probe_protocol(&conn)?;
tracing::info!("Clipboard monitor using protocol: {:?}", protocol);
std::thread::Builder::new()
.name("strata-wl-monitor".into())
.spawn(move || {
if let Err(e) = run_loop(conn, protocol, tx) {
tracing::error!("Clipboard monitor exited with error: {e:#}");
}
})?;
Ok(())
}
#[derive(Debug, Clone, Copy)]
enum Protocol {
Ext,
Wlr,
}
fn probe_protocol(conn: &Connection) -> Result<Protocol> {
// A quick roundtrip to get the global list; no persistent state needed here.
let (globals, _) = registry_queue_init::<MonitorState>(conn)?;
// ext-data-control-v1 preferred (GNOME 47+, KDE Plasma 6)
if globals.contents().with_list(|list| {
list.iter()
.any(|g| g.interface == "ext_data_control_manager_v1")
}) {
return Ok(Protocol::Ext);
}
// zwlr-data-control-v1 fallback (wlroots: Sway, Hyprland, etc.)
if globals.contents().with_list(|list| {
list.iter()
.any(|g| g.interface == "zwlr_data_control_manager_v1")
}) {
return Ok(Protocol::Wlr);
}
bail!(
"No supported clipboard control protocol found.\n\
- GNOME: requires GNOME 47+ (Mutter implements ext-data-control-v1)\n\
- wlroots: requires zwlr-data-control-v1 (Sway, Hyprland, etc.)"
);
}
fn run_loop(
conn: Connection,
protocol: Protocol,
tx: UnboundedSender<ClipboardChange>,
) -> Result<()> {
let (globals, mut queue) = registry_queue_init::<MonitorState>(&conn)?;
let qh = queue.handle();
let mut state = MonitorState::new(tx);
// First roundtrip to populate globals.
queue.roundtrip(&mut state)?;
let seat: wl_seat::WlSeat = globals.bind(&qh, 1..=8, ())?;
match protocol {
Protocol::Ext => {
let manager: ExtDataControlManagerV1 = globals
.bind(&qh, 1..=1, ())
.map_err(|e| anyhow::anyhow!("Could not bind ext_data_control_manager_v1: {e}"))?;
let _device = manager.get_data_device(&seat, &qh, ());
// Roundtrip to ensure the device creation is acknowledged and the
// initial selection offer (if any) is delivered.
queue.roundtrip(&mut state)?;
}
Protocol::Wlr => {
let manager: ZwlrDataControlManagerV1 = globals
.bind(&qh, 2..=2, ())
.map_err(|e| anyhow::anyhow!("Could not bind zwlr_data_control_manager_v1: {e}"))?;
let _device = manager.get_data_device(&seat, &qh, ());
queue.roundtrip(&mut state)?;
}
}
tracing::info!("Clipboard monitor event loop running");
// Main event loop: blocks until an event is available, then dispatches it.
loop {
if let Err(e) = queue.blocking_dispatch(&mut state) {
tracing::error!("Wayland dispatch error: {e}");
break;
}
}
Ok(())
}

View file

@ -0,0 +1,42 @@
/// Writes content back to the Wayland clipboard using wl-clipboard-rs.
/// `copy_multi(foreground: false)` spawns a background OS thread that serves
/// `wl_data_source.send` requests from other apps - the tokio runtime is
/// never blocked.
use anyhow::Result;
use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source};
#[derive(Debug, Clone)]
pub struct WriteRequest {
pub mime_type: String,
pub content: Vec<u8>,
}
pub fn write_to_clipboard(req: WriteRequest) -> Result<()> {
let mime = req.mime_type.clone();
let bytes = req.content.clone();
// For text, offer both the specific type and text/plain for compatibility.
let sources: Vec<MimeSource> = if mime.starts_with("text/") {
vec![
MimeSource {
source: Source::Bytes(bytes.clone().into()),
mime_type: MimeType::Specific(mime.clone()),
},
MimeSource {
source: Source::Bytes(bytes.clone().into()),
mime_type: MimeType::Specific("text/plain;charset=utf-8".into()),
},
]
} else {
vec![MimeSource {
source: Source::Bytes(bytes.into()),
mime_type: MimeType::Specific(mime),
}]
};
let mut opts = Options::new();
opts.foreground(false);
opts.copy_multi(sources)?;
Ok(())
}

View file

@ -0,0 +1,23 @@
use std::path::PathBuf;
pub struct Config {
pub db_path: PathBuf,
pub max_history: usize,
}
impl Config {
pub fn new() -> Self {
let data_dir = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("strata");
if let Err(e) = std::fs::create_dir_all(&data_dir) {
tracing::warn!("Could not create data dir {:?}: {}", data_dir, e);
}
Self {
db_path: data_dir.join("clipboard.db"),
max_history: 200,
}
}
}

336
strata-daemon/src/db.rs Normal file
View file

@ -0,0 +1,336 @@
use anyhow::{Context, Result};
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Mutex, MutexGuard};
/// Metadata for a clipboard entry - does NOT include thumbnail bytes.
/// Thumbnails are fetched separately via `get_thumbnail` for lazy loading.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ItemMeta {
pub id: String,
pub mime_type: String,
/// Non-null for text types (truncated preview for display).
pub content_text: Option<String>,
pub source_app: Option<String>,
pub created_at: i64,
/// True if this item has a stored thumbnail (clients should call get_thumbnail to fetch it).
pub has_thumbnail: bool,
}
/// A row as stored in SQLite (blob is raw bytes, not base64).
#[allow(dead_code)]
pub struct RawItem {
pub id: String,
pub mime_type: String,
pub content_text: Option<String>,
pub content_blob: Option<Vec<u8>>,
pub thumbnail_blob: Option<Vec<u8>>,
pub source_app: Option<String>,
pub created_at: i64,
}
pub struct Db {
conn: Mutex<Connection>,
}
/// Acquire the DB lock, transparently recovering from poison.
///
/// A mutex is "poisoned" when a thread panics while holding it. For our SQLite
/// connection this is safe to recover from because rusqlite uses RAII statements
/// and transactions - any in-flight statement or transaction has already been
/// rolled back by the time the panic unwinds past it. Without this recovery,
/// a single panic anywhere in the daemon would permanently break ALL future
/// database access (every subsequent .lock().unwrap() would panic too).
fn lock_conn(m: &Mutex<Connection>) -> MutexGuard<'_, Connection> {
match m.lock() {
Ok(g) => g,
Err(poisoned) => {
tracing::warn!("DB mutex was poisoned; recovering");
poisoned.into_inner()
}
}
}
impl Db {
pub fn open(path: &Path) -> Result<Self> {
let conn = Connection::open(path)
.with_context(|| format!("Opening SQLite database at {:?}", path))?;
// Performance pragmas: WAL mode for concurrent reads, synchronous=NORMAL is safe
// for a clipboard history (we can tolerate losing the last item on a crash).
conn.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;",
)?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS clipboard_history (
id TEXT PRIMARY KEY,
mime_type TEXT NOT NULL,
content_text TEXT,
content_blob BLOB,
thumbnail_blob BLOB,
content_hash TEXT NOT NULL,
source_app TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_history (created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_hash ON clipboard_history (content_hash);",
)?;
// Migration: add thumbnail_blob column to existing databases.
conn.execute_batch("ALTER TABLE clipboard_history ADD COLUMN thumbnail_blob BLOB;")
.ok(); // Silently ignore error if column already exists.
// FTS5 full-text search index over content_text only.
// Images and other non-text items are NOT indexed - search filters
// them out (an empty search shows everything; a non-empty search
// shows only matching text items).
//
// External-content table linked by rowid avoids duplicating the text.
// unicode61 with diacritic removal gives reasonable matching for
// European languages (e.g. searching "cafe" finds "café").
conn.execute_batch(
"CREATE VIRTUAL TABLE IF NOT EXISTS clipboard_fts USING fts5(
content_text,
content='clipboard_history',
content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
);
CREATE TRIGGER IF NOT EXISTS clipboard_ai AFTER INSERT ON clipboard_history BEGIN
INSERT INTO clipboard_fts(rowid, content_text)
VALUES (new.rowid, new.content_text);
END;
CREATE TRIGGER IF NOT EXISTS clipboard_ad AFTER DELETE ON clipboard_history BEGIN
INSERT INTO clipboard_fts(clipboard_fts, rowid, content_text)
VALUES('delete', old.rowid, old.content_text);
END;
CREATE TRIGGER IF NOT EXISTS clipboard_au AFTER UPDATE ON clipboard_history BEGIN
INSERT INTO clipboard_fts(clipboard_fts, rowid, content_text)
VALUES('delete', old.rowid, old.content_text);
INSERT INTO clipboard_fts(rowid, content_text)
VALUES (new.rowid, new.content_text);
END;",
)?;
// Backfill FTS index from any pre-existing rows. Cheap no-op if already in sync.
let _ = conn.execute_batch("INSERT INTO clipboard_fts(clipboard_fts) VALUES('rebuild');");
Ok(Self {
conn: Mutex::new(conn),
})
}
/// Insert or promote (bump `created_at`) a duplicate item.
/// Returns `(id, is_new)`.
pub fn upsert_item(
&self,
mime_type: &str,
content_text: Option<&str>,
content_blob: Option<&[u8]>,
thumbnail_blob: Option<&[u8]>,
content_hash: &str,
source_app: Option<&str>,
) -> Result<(String, bool)> {
let conn = lock_conn(&self.conn);
let now_ms = chrono_now_ms();
// Check for existing item with this hash.
let existing: Option<String> = conn
.query_row(
"SELECT id FROM clipboard_history WHERE content_hash = ?1",
params![content_hash],
|row| row.get(0),
)
.ok();
if let Some(id) = existing {
conn.execute(
"UPDATE clipboard_history SET created_at = ?1 WHERE id = ?2",
params![now_ms, id],
)?;
return Ok((id, false));
}
let id = uuid::Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO clipboard_history
(id, mime_type, content_text, content_blob, thumbnail_blob, content_hash, source_app, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![id, mime_type, content_text, content_blob, thumbnail_blob, content_hash, source_app, now_ms],
)?;
Ok((id, true))
}
/// Return a page of recent items as metadata (no thumbnail bytes).
/// `offset` is from the most recent item (0 = newest).
pub fn get_history_page(
&self,
offset: usize,
limit: usize,
max_history: usize,
) -> Result<Vec<ItemMeta>> {
let conn = lock_conn(&self.conn);
let effective_limit = limit.min(max_history.saturating_sub(offset));
if effective_limit == 0 {
return Ok(Vec::new());
}
let mut stmt = conn.prepare(
"SELECT id, mime_type, content_text, source_app, created_at,
thumbnail_blob IS NOT NULL AS has_thumb
FROM clipboard_history
ORDER BY created_at DESC
LIMIT ?1 OFFSET ?2",
)?;
let items = stmt
.query_map(params![effective_limit as i64, offset as i64], |row| {
Ok(ItemMeta {
id: row.get(0)?,
mime_type: row.get(1)?,
content_text: row.get(2)?,
source_app: row.get(3)?,
created_at: row.get(4)?,
has_thumbnail: row.get::<_, i64>(5)? != 0,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(items)
}
/// Full-text search over content_text. Only text items can match - images
/// and other non-text content are NOT indexed and never appear in results.
/// Empty query returns []; callers should use get_history_page for that.
pub fn search_history(&self, query: &str, limit: usize) -> Result<Vec<ItemMeta>> {
let conn = lock_conn(&self.conn);
// Build a safe FTS5 prefix query: each token quoted + '*' suffix for prefix match.
let tokens: Vec<String> = query
.split_whitespace()
.filter(|t| !t.is_empty())
.map(|t| {
// Escape embedded double-quotes by doubling them per FTS5 syntax.
let escaped = t.replace('"', "\"\"");
format!("\"{}\"*", escaped)
})
.collect();
if tokens.is_empty() {
return Ok(Vec::new());
}
let fts_query = tokens.join(" ");
let mut stmt = conn.prepare(
"SELECT h.id, h.mime_type, h.content_text, h.source_app, h.created_at,
h.thumbnail_blob IS NOT NULL AS has_thumb
FROM clipboard_history h
WHERE h.rowid IN (SELECT rowid FROM clipboard_fts WHERE clipboard_fts MATCH ?1)
ORDER BY h.created_at DESC
LIMIT ?2",
)?;
let items = stmt
.query_map(params![fts_query, limit as i64], |row| {
Ok(ItemMeta {
id: row.get(0)?,
mime_type: row.get(1)?,
content_text: row.get(2)?,
source_app: row.get(3)?,
created_at: row.get(4)?,
has_thumbnail: row.get::<_, i64>(5)? != 0,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(items)
}
/// Fetch a single item's thumbnail bytes. Returns None if the item has no thumbnail
/// (text item, or doesn't exist).
pub fn get_thumbnail(&self, id: &str) -> Result<Option<Vec<u8>>> {
let conn = lock_conn(&self.conn);
let blob: Option<Option<Vec<u8>>> = conn
.query_row(
"SELECT thumbnail_blob FROM clipboard_history WHERE id = ?1",
params![id],
|row| row.get(0),
)
.ok();
Ok(blob.flatten())
}
/// Fetch raw item content for clipboard write-back.
pub fn get_raw_item(&self, id: &str) -> Result<Option<RawItem>> {
let conn = lock_conn(&self.conn);
let result = conn.query_row(
"SELECT id, mime_type, content_text, content_blob, thumbnail_blob, source_app, created_at
FROM clipboard_history WHERE id = ?1",
params![id],
|row| {
Ok(RawItem {
id: row.get(0)?,
mime_type: row.get(1)?,
content_text: row.get(2)?,
content_blob: row.get(3)?,
thumbnail_blob: row.get(4)?,
source_app: row.get(5)?,
created_at: row.get(6)?,
})
},
).ok();
Ok(result)
}
pub fn delete_item(&self, id: &str) -> Result<bool> {
let conn = lock_conn(&self.conn);
let n = conn.execute("DELETE FROM clipboard_history WHERE id = ?1", params![id])?;
Ok(n > 0)
}
pub fn clear_history(&self) -> Result<()> {
let conn = lock_conn(&self.conn);
conn.execute_batch("DELETE FROM clipboard_history;")?;
Ok(())
}
/// Prune history to `max_history` most recent items. Returns the IDs of
/// items that were deleted, so the caller can emit ItemDeleted signals
/// (lets clients clean up per-item caches like thumbnail files).
pub fn prune(&self, max_history: usize) -> Result<Vec<String>> {
let conn = lock_conn(&self.conn);
let count: i64 = conn.query_row("SELECT COUNT(*) FROM clipboard_history", [], |row| {
row.get(0)
})?;
if (count as usize) <= max_history {
return Ok(Vec::new());
}
let mut stmt = conn.prepare(
"SELECT id FROM clipboard_history
WHERE id NOT IN (
SELECT id FROM clipboard_history ORDER BY created_at DESC LIMIT ?1
)",
)?;
let ids: Vec<String> = stmt
.query_map(params![max_history as i64], |row| row.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
conn.execute(
"DELETE FROM clipboard_history
WHERE id NOT IN (
SELECT id FROM clipboard_history ORDER BY created_at DESC LIMIT ?1
)",
params![max_history as i64],
)?;
Ok(ids)
}
}
fn chrono_now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}

View file

@ -0,0 +1,272 @@
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use base64::Engine as _;
use zbus::{interface, object_server::SignalContext};
use crate::db::{Db, RawItem};
/// Maximum thumbnail dimension for images (px). Larger images are resized down.
pub const THUMB_PX: u32 = 200;
/// Default maximum raw image size to accept (bytes). Overridable via SetConfig.
pub const DEFAULT_MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
/// Default maximum text size to accept. Overridable via SetConfig.
pub const DEFAULT_MAX_TEXT_BYTES: usize = 1024 * 1024;
/// Default maximum history item count. Overridable via SetConfig.
pub const DEFAULT_MAX_HISTORY: usize = 200;
/// Runtime-configurable limits shared between the D-Bus service and the
/// clipboard processing tasks. All three are read on every ingest / prune
/// call, so updates via SetConfig take effect immediately.
#[derive(Clone)]
pub struct Limits {
pub max_history: Arc<AtomicUsize>,
pub max_text_bytes: Arc<AtomicUsize>,
pub max_image_bytes: Arc<AtomicUsize>,
}
impl Limits {
pub fn with_defaults() -> Self {
Self {
max_history: Arc::new(AtomicUsize::new(DEFAULT_MAX_HISTORY)),
max_text_bytes: Arc::new(AtomicUsize::new(DEFAULT_MAX_TEXT_BYTES)),
max_image_bytes: Arc::new(AtomicUsize::new(DEFAULT_MAX_IMAGE_BYTES)),
}
}
pub fn max_history(&self) -> usize {
self.max_history.load(Ordering::Relaxed)
}
pub fn max_text_bytes(&self) -> usize {
self.max_text_bytes.load(Ordering::Relaxed)
}
pub fn max_image_bytes(&self) -> usize {
self.max_image_bytes.load(Ordering::Relaxed)
}
}
/// Submitted by the GJS extension via D-Bus (Meta.Selection path).
pub struct SubmitRequest {
pub mime_type: String,
pub bytes: Vec<u8>,
}
pub struct StrataManager {
pub db: Arc<Db>,
pub limits: Limits,
pub focused_app: Arc<tokio::sync::Mutex<String>>,
pub submit_tx: tokio::sync::mpsc::UnboundedSender<SubmitRequest>,
/// Send a `()` here to trigger graceful shutdown from the D-Bus Shutdown method.
pub shutdown_tx: tokio::sync::mpsc::UnboundedSender<()>,
}
#[interface(name = "org.gnome.Strata.Manager")]
impl StrataManager {
/// Called by the GJS extension when it detects a clipboard change via Meta.Selection.
/// `content` is the raw bytes of the clipboard payload (D-Bus `ay`).
async fn submit_item(&self, mime_type: String, content: Vec<u8>) -> zbus::fdo::Result<()> {
self.submit_tx
.send(SubmitRequest {
mime_type,
bytes: content,
})
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
}
/// Return a page of recent items as JSON metadata (no inline thumbnail bytes).
/// Clients call GetThumbnail(id) lazily for items with has_thumbnail=true.
/// `offset` is from the most recent item (0 = newest).
async fn get_history(&self, offset: u32, limit: u32) -> zbus::fdo::Result<String> {
let db = self.db.clone();
let max = self.limits.max_history();
let items = tokio::task::spawn_blocking(move || {
db.get_history_page(offset as usize, limit as usize, max)
})
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
serde_json::to_string(&items).map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
}
/// Full-text search across the entire DB. Empty query returns []; callers
/// should fall back to GetHistory in that case.
async fn search_history(&self, query: String, limit: u32) -> zbus::fdo::Result<String> {
let db = self.db.clone();
let items = tokio::task::spawn_blocking(move || db.search_history(&query, limit as usize))
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
serde_json::to_string(&items).map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
}
/// Fetch a thumbnail's raw PNG bytes. Returns empty array if the item has no
/// thumbnail or doesn't exist (clients can fall back to a placeholder).
async fn get_thumbnail(&self, id: String) -> zbus::fdo::Result<Vec<u8>> {
let db = self.db.clone();
let bytes = tokio::task::spawn_blocking(move || db.get_thumbnail(&id))
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
Ok(bytes.unwrap_or_default())
}
/// Return the raw content of a clipboard item for GJS to write to clipboard.
/// GJS uses St.Clipboard (text) or Meta.SelectionSourceMemory (images)
/// because the Wayland data-control protocol is not accessible from non-compositor processes.
async fn get_item_content(&self, id: String) -> zbus::fdo::Result<(String, String)> {
let db = self.db.clone();
let item: Option<RawItem> = tokio::task::spawn_blocking(move || db.get_raw_item(&id))
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
let item = item.ok_or_else(|| zbus::fdo::Error::Failed("Item not found".into()))?;
let content_b64 = if let Some(text) = item.content_text {
base64::engine::general_purpose::STANDARD.encode(text.as_bytes())
} else if let Some(blob) = item.content_blob {
base64::engine::general_purpose::STANDARD.encode(&blob)
} else {
return Err(zbus::fdo::Error::Failed("Item has no content".into()));
};
Ok((item.mime_type, content_b64))
}
/// Restore a clipboard item to the system clipboard.
async fn set_clipboard(&self, id: String) -> zbus::fdo::Result<()> {
let db = self.db.clone();
let item: Option<RawItem> = tokio::task::spawn_blocking(move || db.get_raw_item(&id))
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
let item = item.ok_or_else(|| zbus::fdo::Error::Failed("Item not found".into()))?;
let content = if let Some(text) = item.content_text {
crate::clipboard::writer::WriteRequest {
mime_type: item.mime_type,
content: text.into_bytes(),
}
} else if let Some(blob) = item.content_blob {
crate::clipboard::writer::WriteRequest {
mime_type: item.mime_type,
content: blob,
}
} else {
return Err(zbus::fdo::Error::Failed("Item has no content".into()));
};
tokio::task::spawn_blocking(move || crate::clipboard::writer::write_to_clipboard(content))
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
}
/// Remove a single item from history.
async fn delete_item(
&self,
id: String,
#[zbus(signal_context)] ctx: SignalContext<'_>,
) -> zbus::fdo::Result<()> {
let db = self.db.clone();
let id_clone = id.clone();
tokio::task::spawn_blocking(move || db.delete_item(&id_clone))
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
Self::item_deleted(&ctx, &id)
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
}
/// Remove all items from history.
async fn clear_history(
&self,
#[zbus(signal_context)] ctx: SignalContext<'_>,
) -> zbus::fdo::Result<()> {
let db = self.db.clone();
tokio::task::spawn_blocking(move || db.clear_history())
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
Self::history_cleared(&ctx)
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
}
/// Called by the GJS extension on every focused-window change.
/// Used for app exclusion: if the focused app is in the exclusion list,
/// the extension will immediately DeleteItem any new item from that app.
async fn set_focused_app(&self, app_id: String) {
*self.focused_app.lock().await = app_id;
}
/// Push runtime limits from the front-end. Takes effect immediately.
/// All three values are absolute (max_text and max_image are bytes,
/// not MB). 0 means "leave unchanged" for that field.
async fn set_config(
&self,
max_history: u32,
max_text_bytes: u32,
max_image_bytes: u32,
) -> zbus::fdo::Result<()> {
use std::sync::atomic::Ordering;
if max_history > 0 {
self.limits
.max_history
.store(max_history as usize, Ordering::Relaxed);
}
if max_text_bytes > 0 {
self.limits
.max_text_bytes
.store(max_text_bytes as usize, Ordering::Relaxed);
}
if max_image_bytes > 0 {
self.limits
.max_image_bytes
.store(max_image_bytes as usize, Ordering::Relaxed);
}
tracing::info!(
"SetConfig: max_history={}, max_text={} B, max_image={} B",
self.limits.max_history(),
self.limits.max_text_bytes(),
self.limits.max_image_bytes(),
);
Ok(())
}
/// Gracefully shut down the daemon.
async fn shutdown(&self) {
tracing::info!("Shutdown requested via D-Bus");
// Signal the main loop to exit. The reply is sent before the process exits
// because we return normally from this method; the main loop exits shortly after.
let _ = self.shutdown_tx.send(());
}
// -----------------------------------------------------------------
// Signals
// -----------------------------------------------------------------
/// Emitted when a new item is stored (after dedup + thumbnail generation).
/// `preview` is a text excerpt (≤120 chars) for text items, or empty for
/// images and other binary types - clients should call GetThumbnail(id) to
/// fetch image thumbnails lazily.
#[zbus(signal)]
pub async fn item_added(
ctx: &SignalContext<'_>,
id: &str,
mime_type: &str,
preview: &str,
) -> zbus::Result<()>;
/// Emitted when an item is removed.
#[zbus(signal)]
pub async fn item_deleted(ctx: &SignalContext<'_>, id: &str) -> zbus::Result<()>;
/// Emitted when the entire history is cleared.
#[zbus(signal)]
pub async fn history_cleared(ctx: &SignalContext<'_>) -> zbus::Result<()>;
}

363
strata-daemon/src/main.rs Normal file
View file

@ -0,0 +1,363 @@
mod clipboard;
mod config;
mod db;
mod dbus_service;
use std::sync::Arc;
use anyhow::{Context, Result};
use base64::Engine as _;
use dbus_service::{Limits, StrataManager, SubmitRequest, THUMB_PX};
use tokio::sync::{mpsc, Mutex};
use tracing_subscriber::EnvFilter;
use wl_clipboard_rs::paste::{get_contents, ClipboardType, MimeType, Seat};
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
tracing::info!("Strata daemon starting");
let cfg = config::Config::new();
let db = Arc::new(db::Db::open(&cfg.db_path).context("Opening database")?);
let focused_app = Arc::new(Mutex::new(String::new()));
let limits = Limits::with_defaults();
limits
.max_history
.store(cfg.max_history, std::sync::atomic::Ordering::Relaxed);
// -----------------------------------------------------------------------
// Channels
// -----------------------------------------------------------------------
// Wayland-monitor path: daemon reads clipboard bytes itself.
let (clip_tx, mut clip_rx) = mpsc::unbounded_channel::<clipboard::monitor::ClipboardChange>();
// GJS submit path: GJS reads via Meta.Selection, sends bytes over D-Bus.
let (submit_tx, mut submit_rx) = mpsc::unbounded_channel::<SubmitRequest>();
// D-Bus Shutdown method path.
let (shutdown_tx, mut shutdown_rx) = mpsc::unbounded_channel::<()>();
// -----------------------------------------------------------------------
// D-Bus service
// -----------------------------------------------------------------------
let manager = StrataManager {
db: db.clone(),
limits: limits.clone(),
focused_app: focused_app.clone(),
submit_tx,
shutdown_tx,
};
let dbus_conn = zbus::connection::Builder::session()?
.serve_at("/org/gnome/Strata", manager)?
.build()
.await
.context("Building D-Bus connection")?;
// Request the well-known name with ReplaceExisting so a new instance always
// wins the race against the outgoing instance during extension reload.
dbus_conn
.request_name_with_flags(
"org.gnome.Strata",
zbus::fdo::RequestNameFlags::ReplaceExisting.into(),
)
.await
.context("Acquiring D-Bus name org.gnome.Strata")?;
tracing::info!("D-Bus service registered as org.gnome.Strata");
// -----------------------------------------------------------------------
// Clipboard monitor (Wayland protocols - optional, soft-fail on GNOME)
// GJS provides clipboard content via SubmitItem when this is unavailable.
// -----------------------------------------------------------------------
match clipboard::monitor::spawn(clip_tx) {
Ok(()) => tracing::info!("Wayland clipboard monitor started"),
Err(_) => tracing::info!(
"Wayland clipboard monitor unavailable, using GJS Meta.Selection path (expected on GNOME)"
),
}
// -----------------------------------------------------------------------
// Clipboard processing tasks
// -----------------------------------------------------------------------
let db_clone = db.clone();
let conn_clone = dbus_conn.clone();
let limits_clone = limits.clone();
// Wayland-monitor path.
tokio::spawn(async move {
while let Some(change) = clip_rx.recv().await {
if let Err(e) =
process_change(&change.mime_types, &db_clone, &conn_clone, &limits_clone).await
{
tracing::warn!("Error processing Wayland clipboard change: {e:#}");
}
}
});
let db_submit = db.clone();
let conn_submit = dbus_conn.clone();
let limits_submit = limits.clone();
// GJS submit path.
tokio::spawn(async move {
while let Some(req) = submit_rx.recv().await {
if let Err(e) = process_bytes(
req.mime_type,
req.bytes,
&db_submit,
&conn_submit,
&limits_submit,
)
.await
{
tracing::warn!("Error processing submitted clipboard item: {e:#}");
}
}
});
// -----------------------------------------------------------------------
// Graceful shutdown on SIGTERM / SIGINT
// -----------------------------------------------------------------------
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
tokio::select! {
_ = sigterm.recv() => { tracing::info!("Received SIGTERM, shutting down"); }
_ = sigint.recv() => { tracing::info!("Received SIGINT, shutting down"); }
_ = shutdown_rx.recv() => { tracing::info!("Shutdown via D-Bus, shutting down"); }
}
Ok(())
}
/// Select the best MIME type to store from a list of offered types.
///
/// Priority: images > plain text > rich text (html/rtf) > file URIs.
///
/// Plain text is intentionally ranked above text/html and text/rtf so that
/// when an application (e.g. a code editor or web browser) advertises both a
/// rich-text variant and a plain-text variant, we capture the plain text. This
/// avoids storing syntax-highlighted HTML markup instead of source code.
/// Rich-text types are still captured when no plain-text alternative is offered.
fn pick_mime(mimes: &[String]) -> Option<&str> {
const PREFERRED: &[&str] = &[
// Raster images (size-capped at MAX_IMAGE_BYTES).
"image/png",
"image/jpeg",
"image/jpg",
"image/gif",
"image/webp",
"image/bmp",
"image/tiff",
"image/avif",
"image/x-icon",
"image/svg+xml",
// Plain text (UTF-8 preferred, then locale, then X11 legacy aliases).
// Ranked above rich-text so editors that expose both text/plain and
// text/html give us the raw source, not styled markup.
"text/plain;charset=utf-8",
"UTF8_STRING",
"text/plain",
"STRING",
"TEXT",
// Rich text -- only reached when no plain-text offer is available.
"text/html",
"text/rtf",
"application/rtf",
"text/markdown",
// File-manager copy/cut payloads (URI list, not bytes).
"x-special/gnome-copied-files",
"x-special/nautilus-clipboard",
"application/x-kde-cutselection",
"text/uri-list",
];
// Allowlist only: accepting arbitrary mimes would force us to read
// potentially huge payloads (1 GB binaries, etc.) before we could
// size-check them. Unknown mimes are silently dropped.
PREFERRED
.iter()
.find(|&&want| mimes.iter().any(|m| m == want))
.copied()
}
async fn process_change(
mime_types: &[String],
db: &Arc<db::Db>,
conn: &zbus::Connection,
limits: &Limits,
) -> Result<()> {
// Password managers (KeePassXC, KeePass, some Bitwarden builds) mark
// copied secrets with this mime hint. Skip the change entirely so we
// never store the password.
if mime_types.iter().any(|m| m == "x-kde-passwordManagerHint") {
tracing::debug!("Skipping clipboard change marked as sensitive");
return Ok(());
}
let mime = match pick_mime(mime_types) {
Some(m) => m.to_string(),
None => {
tracing::debug!("No usable MIME type in {:?}", mime_types);
return Ok(());
}
};
tracing::debug!("Processing Wayland clipboard change, MIME: {}", mime);
let mime_clone = mime.clone();
let (raw_bytes, actual_mime) =
tokio::task::spawn_blocking(move || read_clipboard_bytes(&mime_clone))
.await?
.context("Reading clipboard content")?;
if raw_bytes.is_empty() {
return Ok(());
}
process_bytes(actual_mime, raw_bytes, db, conn, limits).await
}
/// Core storage path - shared by the Wayland-monitor and GJS-submit paths.
async fn process_bytes(
mime: String,
raw_bytes: Vec<u8>,
db: &Arc<db::Db>,
conn: &zbus::Connection,
limits: &Limits,
) -> Result<()> {
if raw_bytes.is_empty() {
return Ok(());
}
let is_image = mime.starts_with("image/");
// Enforce size limits
let max = if is_image {
limits.max_image_bytes()
} else {
limits.max_text_bytes()
};
if raw_bytes.len() > max {
tracing::debug!(
"Clipboard item too large ({} bytes, limit {}), skipping",
raw_bytes.len(),
max
);
return Ok(());
}
// Compute content hash for deduplication
let hash = blake3::hash(&raw_bytes).to_hex().to_string();
// For images: generate a thumbnail in spawn_blocking; store original as blob, thumbnail separately.
// For text: store as UTF-8 string.
// The `preview` string emitted with the ItemAdded signal is now ALWAYS a text excerpt;
// image thumbnails are fetched lazily by clients via GetThumbnail(id) to keep the
// signal payload small (and consistent with paginated GetHistory results).
let (content_text, content_blob, thumbnail_blob, preview) = if is_image {
let raw_for_thumb = raw_bytes.clone();
let thumb_result =
tokio::task::spawn_blocking(move || make_thumbnail(&raw_for_thumb, THUMB_PX)).await?;
let thumb_bytes = match thumb_result {
Ok((bytes, _b64)) => bytes,
Err(e) => {
tracing::warn!("Image thumbnail generation failed: {e}");
return Ok(());
}
};
(None, Some(raw_bytes), Some(thumb_bytes), String::new())
} else {
let text = String::from_utf8_lossy(&raw_bytes).into_owned();
let preview: String = text.chars().take(120).collect();
(Some(text), None, None, preview)
};
let db_clone = db.clone();
let mime_clone = mime.clone();
let hash_clone = hash.clone();
let (id, is_new) = tokio::task::spawn_blocking(move || {
db_clone.upsert_item(
&mime_clone,
content_text.as_deref(),
content_blob.as_deref(),
thumbnail_blob.as_deref(),
&hash_clone,
None, // source_app is set via SetFocusedApp
)
})
.await?
.context("Upserting clipboard item")?;
// Prune old items to stay within max_history limit.
// Only needed when a genuinely new item was just inserted.
let pruned_ids: Vec<String> = if is_new {
let db_prune = db.clone();
let max_history = limits.max_history();
tokio::task::spawn_blocking(move || db_prune.prune(max_history))
.await?
.context("Pruning history")?
} else {
Vec::new()
};
if is_new {
let iface = conn
.object_server()
.interface::<_, StrataManager>("/org/gnome/Strata")
.await
.context("Getting StrataManager interface ref")?;
StrataManager::item_added(iface.signal_context(), &id, &mime, &preview)
.await
.context("Emitting ItemAdded signal")?;
for pid in &pruned_ids {
if let Err(e) = StrataManager::item_deleted(iface.signal_context(), pid).await {
tracing::warn!("Emitting ItemDeleted for pruned id={}: {}", pid, e);
}
}
tracing::debug!("Stored new clipboard item id={} mime={}", id, mime);
} else {
tracing::debug!("Promoted duplicate clipboard item id={}", id);
}
Ok(())
}
/// Read clipboard content using wl-clipboard-rs (opens its own Wayland connection).
/// Returns `(bytes, actual_mime_type)`.
fn read_clipboard_bytes(mime: &str) -> Result<(Vec<u8>, String)> {
use std::io::Read;
let (mut reader, actual_mime) = get_contents(
ClipboardType::Regular,
Seat::Unspecified,
MimeType::Specific(mime),
)
.map_err(|e| anyhow::anyhow!("wl-clipboard-rs read error: {e:?}"))?;
let mut buf = Vec::new();
reader.read_to_end(&mut buf)?;
Ok((buf, actual_mime))
}
/// Generate a PNG thumbnail (≤`max_px`×`max_px`) from raw image bytes.
/// Returns `(thumbnail_bytes, base64_preview)`.
fn make_thumbnail(raw: &[u8], max_px: u32) -> Result<(Vec<u8>, String)> {
let img = image::load_from_memory(raw).context("Decoding image")?;
let thumb = img.thumbnail(max_px, max_px);
let mut out = Vec::new();
thumb
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.context("Encoding thumbnail")?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&out);
Ok((out, b64))
}

View file

@ -0,0 +1,84 @@
# Strata GNOME Shell extension
The GJS front-end for [Strata](../README.md). Renders the panel UI and forwards
clipboard events to [`strata-daemon`](../strata-daemon/README.md) over D-Bus.
> The extension requires `strata-daemon` to be installed and available in
> `$PATH`. See the [main README](../README.md) for install instructions.
> The extension is **pure JavaScript** - no binary is bundled.
>
> **Compatibility:** Only tested on GNOME Shell 50. The `metadata.json`
> declares support for 4550 since the APIs used (`Meta.Selection`,
> `St`, `Gio`, `Adw`) have been stable across that range, but older
> versions are untested. If you try one and it works, open an issue to
> let us know.
## File layout
```
strata@edu4rdshl.dev/
├── extension.js Lifecycle, daemon lookup/supervisor, clipboard ingest
├── prefs.js Preferences window
├── dbus.js Generated D-Bus proxy + interface XML
├── stylesheet.css Panel styles
├── metadata.json GNOME Shell metadata (tested on Shell 50; declares 4550)
├── schemas/ GSettings schema (max history, size caps, shortcut)
└── ui/
├── panel.js Popup panel: pagination, search, scroll-to-load
└── clipboardItem.js Per-item row (text preview or lazy thumbnail)
```
## How it works
1. On enable, `extension.js` checks if the D-Bus name is already owned
(daemon running via systemd). If not, it looks for `strata-daemon` in
`$PATH` and spawns it, auto-respawning on crash with exponential backoff.
2. `Meta.Selection` notifies us of every clipboard change. We read the raw
bytes (allowlisted mimes only, password-manager hint skipped) and ship
them to the daemon via `SubmitItem(s, ay)`.
3. The daemon dedups, stores, indexes, and emits `ItemAdded`.
4. `ui/panel.js` paginates history with `GetHistory`, debounces search with
`SearchHistoryAsync`, and writes back via `St.Clipboard.set_text` or
`Meta.SelectionSourceMemory` (never `spawn`, never `launch_uri`).
## Settings
Open with `gnome-extensions prefs strata@edu4rdshl.dev`:
**History**
- **Max history** (502000 items)
- **Items per page** (20200, rows fetched on open and on each scroll-to-bottom)
- **Maximum text size** (1100 MB, larger items are not stored)
- **Maximum image size** (1100 MB, larger items are not stored)
**Appearance**
- **Panel width** (pixels)
- **Panel max height** (pixels, list scrolls past this)
- **Move activated item to top**
- **Panel position** (top-left, top-center, top-right)
**Keyboard**
- **Open Strata** shortcut
**Privacy**
- **App exclusions** (apps whose clipboard is never recorded)
All four history/size limits are pushed to the daemon over D-Bus (`SetConfig`) as soon
as you change them. No restart needed.
## Develop
```sh
# Build daemon + install extension + recompile schemas
make -C .. install-daemon && make -C .. install
# Live-reload logs
journalctl --user -f /usr/bin/gnome-shell | grep -i strata
```
If you only change JS, you can re-`make install` and restart the Shell
(Wayland: log out / in; X11: `Alt+F2`, `r`).
## License
GPL-3.0-or-later.

View file

@ -0,0 +1,83 @@
/**
* dbus.js - Async D-Bus proxy for the Strata daemon.
*
* Uses Gio.DBusProxy.makeProxyWrapper to generate a typed proxy class.
* All method calls should use the *Async variants (return Promises) to avoid
* blocking the GNOME Shell compositor main loop.
*/
import Gio from 'gi://Gio';
const STRATA_IFACE_XML = `
<node>
<interface name="org.gnome.Strata.Manager">
<method name="GetHistory">
<arg type="u" direction="in" name="offset"/>
<arg type="u" direction="in" name="limit"/>
<arg type="s" direction="out" name="json"/>
</method>
<method name="SearchHistory">
<arg type="s" direction="in" name="query"/>
<arg type="u" direction="in" name="limit"/>
<arg type="s" direction="out" name="json"/>
</method>
<method name="GetThumbnail">
<arg type="s" direction="in" name="id"/>
<arg type="ay" direction="out" name="png_bytes"/>
</method>
<method name="GetItemContent">
<arg type="s" direction="in" name="id"/>
<arg type="s" direction="out" name="mime_type"/>
<arg type="s" direction="out" name="content_b64"/>
</method>
<method name="SetClipboard">
<arg type="s" direction="in" name="id"/>
</method>
<method name="DeleteItem">
<arg type="s" direction="in" name="id"/>
</method>
<method name="ClearHistory"/>
<method name="SetFocusedApp">
<arg type="s" direction="in" name="app_id"/>
</method>
<method name="Shutdown"/>
<method name="SetConfig">
<arg type="u" direction="in" name="max_history"/>
<arg type="u" direction="in" name="max_text_bytes"/>
<arg type="u" direction="in" name="max_image_bytes"/>
</method>
<method name="SubmitItem">
<arg type="s" direction="in" name="mime_type"/>
<arg type="ay" direction="in" name="content"/>
</method>
<signal name="ItemAdded">
<arg type="s" name="id"/>
<arg type="s" name="mime_type"/>
<arg type="s" name="preview"/>
</signal>
<signal name="ItemDeleted">
<arg type="s" name="id"/>
</signal>
<signal name="HistoryCleared"/>
</interface>
</node>`;
export const StrataProxy = Gio.DBusProxy.makeProxyWrapper(STRATA_IFACE_XML);
export const BUS_NAME = 'org.gnome.Strata';
export const OBJECT_PATH = '/org/gnome/Strata';

View file

@ -0,0 +1,582 @@
/* extension.js - Strata extension lifecycle. */
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import St from 'gi://St';
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
import { StrataProxy, BUS_NAME, OBJECT_PATH } from './dbus.js';
import { StrataPanel } from './ui/panel.js';
export default class StrataExtension extends Extension {
/** @type {Gio.Subprocess | null} */
_daemon = null;
_daemonSpawnTime = 0;
_daemonRestartAttempts = 0;
_shuttingDown = false;
_daemonRestartTimerId = null;
/** @type {StrataPanel | null} */
_panel = null;
/** @type {object | null} Gio.DBusProxy instance */
_proxy = null;
_itemAddedId = null;
_itemDeletedId = null;
_historyClearedId = null;
/** @type {number | null} focused-window signal connection ID */
_focusSignalId = null;
/** @type {string} WM class of the currently focused app (lower-cased) */
_currentFocusedApp = '';
/** @type {object | null} PanelMenu.Button indicator */
_indicator = null;
/** @type {number | null} Meta.Selection owner-changed signal ID */
_selectionChangedId = null;
/** @type {number | null} Debounce timer for clipboard reads */
_clipboardDebounceId = null;
/** True while a transfer_async is in flight - prevents concurrent transfers. */
_clipboardTransferPending = false;
/** @type {string[]} Apps to exclude from history */
_excludedApps = [];
_pendingSignalId = null;
/** @type {boolean} Re-entrancy guard for signal processing */
#busy = false;
/** @type {number | null} Keyboard shortcut binding ID */
_shortcutId = null;
enable() {
this._shuttingDown = false;
this._daemonRestartAttempts = 0;
this._settings = this.getSettings();
this._excludedApps = this._settings.get_strv('excluded-apps');
this._settings.connect('changed::excluded-apps', () => {
this._excludedApps = this._settings.get_strv('excluded-apps');
});
this._readSizeLimits();
this._configChangedIds = [
this._settings.connect('changed::max-history', () => { this._readSizeLimits(); this._pushConfig(); }),
this._settings.connect('changed::max-text-mb', () => { this._readSizeLimits(); this._pushConfig(); }),
this._settings.connect('changed::max-image-mb', () => { this._readSizeLimits(); this._pushConfig(); }),
];
// 1. Top-bar indicator icon.
this._addIndicator();
// 2. Spawn the Rust daemon.
this._spawnDaemon();
// 3. Connect D-Bus proxy (async - doesn't block if daemon isn't ready yet).
this._connectProxy();
// 4. Track focused window (lightweight - no clipboard I/O).
this._connectFocusTracking();
// 5. Monitor clipboard via Meta.Selection (GNOME-native, no Wayland protocol needed).
this._connectClipboardMonitor();
// 6. Register keyboard shortcut.
this._registerShortcut();
}
disable() {
// Signal the watchdog NOT to respawn on exit.
this._shuttingDown = true;
if (this._daemonRestartTimerId !== null) {
GLib.Source.remove(this._daemonRestartTimerId);
this._daemonRestartTimerId = null;
}
// Clean up in reverse order.
this._unregisterShortcut();
this._disconnectClipboardMonitor();
this._disconnectFocusTracking();
this._disconnectSignals();
if (this._proxyOwnerId && this._proxy) {
this._proxy.disconnect(this._proxyOwnerId);
this._proxyOwnerId = 0;
}
if (this._configChangedIds && this._settings) {
for (const id of this._configChangedIds) this._settings.disconnect(id);
this._configChangedIds = null;
}
this._panel?.destroy();
this._panel = null;
this._indicator?.destroy();
this._indicator = null;
this._stopDaemon();
this._proxy = null;
this._settings = null;
}
_addIndicator() {
// dontCreateMenu = true so PanelMenu.Button doesn't open an empty popup.
this._indicator = new PanelMenu.Button(0.0, 'Strata', true);
const icon = new St.Icon({
icon_name: 'edit-paste-symbolic',
style_class: 'system-status-icon',
});
this._indicator.add_child(icon);
this._indicator.connect('button-press-event', () => {
this._panel?.toggle();
return false; // EVENT_PROPAGATE
});
Main.panel.addToStatusArea('strata', this._indicator);
}
_connectClipboardMonitor() {
const selection = global.display.get_selection();
this._selectionChangedId = selection.connect('owner-changed', (_sel, type) => {
if (type !== Meta.SelectionType.SELECTION_CLIPBOARD) return;
this._scheduleClipboardRead();
});
}
_disconnectClipboardMonitor() {
// Cancel any pending debounce timer and reset the in-flight guard so
// the next enable() cycle starts clean.
if (this._clipboardDebounceId !== null) {
GLib.Source.remove(this._clipboardDebounceId);
this._clipboardDebounceId = null;
}
this._clipboardTransferPending = false;
if (this._selectionChangedId !== null) {
global.display.get_selection().disconnect(this._selectionChangedId);
this._selectionChangedId = null;
}
}
/** Debounce entry point - coalesces rapid clipboard changes (e.g. from
* apps that write clipboard multiple times per operation). */
_scheduleClipboardRead() {
if (this._clipboardDebounceId !== null) {
GLib.Source.remove(this._clipboardDebounceId);
this._clipboardDebounceId = null;
}
this._clipboardDebounceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 50, () => {
this._clipboardDebounceId = null;
this._readClipboard();
return GLib.SOURCE_REMOVE;
});
}
/** Single in-flight transfer at a time - prevents queuing multiple concurrent
* transfer_async + base64_encode operations that would stall the main thread. */
_readClipboard() {
if (this._clipboardTransferPending) return;
const selection = global.display.get_selection();
const mimes = selection.get_mimetypes(Meta.SelectionType.SELECTION_CLIPBOARD);
// Password managers (KeePassXC, KeePass, some Bitwarden builds) mark
// copied secrets with this hint mime. Honoring it lets users keep
// their passwords out of clipboard history.
if (mimes.includes('x-kde-passwordManagerHint')) return;
const mime = this._pickMime(mimes);
if (!mime) return;
this._clipboardTransferPending = true;
const outputStream = Gio.MemoryOutputStream.new_resizable();
selection.transfer_async(
Meta.SelectionType.SELECTION_CLIPBOARD,
mime,
-1,
outputStream,
null,
(_obj, result) => {
this._clipboardTransferPending = false;
try {
selection.transfer_finish(result);
outputStream.close(null);
const bytes = outputStream.steal_as_bytes();
const size = bytes.get_size();
if (size === 0) return;
const MAX_TEXT = this._maxTextBytes;
const MAX_IMAGE = this._maxImageBytes;
if (size > (mime.startsWith('image/') ? MAX_IMAGE : MAX_TEXT)) return;
// Send raw bytes as a D-Bus `ay` so we avoid blocking
// synchronous base64 work on the GJS main thread.
this._proxy?.SubmitItemRemote(mime, bytes.get_data(), () => {});
} catch (e) {
console.error('[Strata] Clipboard read error:', e.message);
}
}
);
}
/** Pick the best MIME type to store from the offered list (mirrors Rust pick_mime). */
_pickMime(mimes) {
const PREFERRED = [
// Raster images (size-capped at MAX_IMAGE).
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
'image/bmp', 'image/tiff', 'image/avif', 'image/x-icon',
'image/svg+xml',
// Plain text (UTF-8 preferred, then locale, then X11 legacy aliases).
// Ranked above rich-text so editors that expose both text/plain and
// text/html give us raw source, not styled markup.
'text/plain;charset=utf-8', 'UTF8_STRING',
'text/plain', 'STRING', 'TEXT',
// Rich text -- only reached when no plain-text offer is available.
'text/html',
'text/rtf', 'application/rtf',
'text/markdown',
// File-manager copy/cut payloads (URI list, not bytes).
'x-special/gnome-copied-files',
'x-special/nautilus-clipboard',
'application/x-kde-cutselection',
'text/uri-list',
];
for (const want of PREFERRED)
if (mimes.includes(want)) return want;
// Allowlist only: see comment in pick_mime (daemon). Reading unknown
// mime types could pull a 1 GB blob into Shell memory before we can
// size-check it.
return null;
}
_spawnDaemon() {
if (this._shuttingDown) return;
// Check if a daemon is already running (e.g. via systemd user service).
// If the D-Bus name is already owned we must not spawn a second instance.
Gio.DBus.session.call(
'org.freedesktop.DBus', '/org/freedesktop/DBus',
'org.freedesktop.DBus', 'GetNameOwner',
new GLib.Variant('(s)', [BUS_NAME]),
null, Gio.DBusCallFlags.NONE, 2000, null,
(_conn, result) => {
try {
_conn.call_finish(result);
// Name already owned - daemon managed externally (systemd etc).
console.log('[Strata] daemon already running, skipping spawn');
} catch (_) {
// Name not owned - spawn it ourselves.
this._doSpawnDaemon();
}
}
);
}
_doSpawnDaemon() {
if (this._shuttingDown) return;
const daemonPath = GLib.find_program_in_path('strata-daemon');
if (!daemonPath) {
console.error(
'[Strata] strata-daemon not found in PATH. ' +
'Install the strata-daemon package or place the binary in your PATH.'
);
this._notifyDaemonMissing();
return;
}
try {
this._daemon = new Gio.Subprocess({
argv: [daemonPath],
flags: Gio.SubprocessFlags.NONE,
});
this._daemon.init(null);
this._daemonSpawnTime = GLib.get_monotonic_time() / 1000; // ms
this._daemon.wait_async(null, () => this._onDaemonExited());
} catch (e) {
console.error('[Strata] Failed to spawn daemon:', e);
this._scheduleDaemonRestart();
}
}
_notifyDaemonMissing() {
try {
const source = new imports.ui.messageTray.Source({
title: 'Strata',
icon: new St.Icon({ icon_name: 'edit-paste-symbolic' }),
});
Main.messageTray.add(source);
const notification = new imports.ui.messageTray.Notification({
source,
title: 'Strata: daemon not found',
body: 'Install the strata-daemon package to enable clipboard history.',
urgency: imports.ui.messageTray.Urgency.HIGH,
});
source.addNotification(notification);
} catch (_) {
// Message tray may not be available (e.g. during early startup) - already logged above.
}
}
_onDaemonExited() {
if (!this._daemon) return; // shutdown initiated by _stopDaemon
const exit = this._daemon.get_exit_status();
const lifetimeMs = (GLib.get_monotonic_time() / 1000) - this._daemonSpawnTime;
this._daemon = null;
if (this._shuttingDown) {
console.log(`[Strata] daemon exited cleanly during shutdown (status=${exit})`);
return;
}
// Reset attempt counter if the daemon ran long enough to be considered healthy.
if (lifetimeMs >= 5000) {
this._daemonRestartAttempts = 0;
}
this._daemonRestartAttempts++;
console.error(
`[Strata] daemon exited with status ${exit} after ${Math.round(lifetimeMs)}ms ` +
`(restart attempt ${this._daemonRestartAttempts})`
);
if (this._daemonRestartAttempts > 5) {
console.error(
'[Strata] Daemon crashed 5 times in rapid succession - giving up. ' +
'Disable and re-enable the extension to retry.'
);
return;
}
this._scheduleDaemonRestart();
}
_scheduleDaemonRestart() {
if (this._shuttingDown) return;
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
const backoffMs = 1000 * Math.pow(2, Math.max(0, this._daemonRestartAttempts - 1));
console.log(`[Strata] respawning daemon in ${backoffMs}ms`);
this._daemonRestartTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, backoffMs, () => {
this._daemonRestartTimerId = null;
this._spawnDaemon();
return GLib.SOURCE_REMOVE;
});
}
_stopDaemon() {
if (!this._daemon) return;
// Capture reference immediately so the timer doesn't kill a newly-spawned daemon.
const daemonToStop = this._daemon;
this._daemon = null;
try {
// Graceful shutdown via D-Bus first.
this._proxy?.ShutdownRemote(() => {});
} catch (_) {}
// Give it 1.5s then force-terminate.
GLib.timeout_add(GLib.PRIORITY_LOW, 1500, () => {
try { daemonToStop.send_signal(15); } catch (_) {} // SIGTERM
return GLib.SOURCE_REMOVE;
});
}
_connectProxy() {
try {
this._proxy = new StrataProxy(
Gio.DBus.session,
BUS_NAME,
OBJECT_PATH,
(proxy, error) => {
if (error) {
console.error('[Strata] D-Bus proxy error:', error);
return;
}
this._connectSignals();
this._panel = new StrataPanel(proxy, this._settings, this._indicator);
// Push config now if the daemon is already up, and
// again on every owner transition so a respawned
// daemon picks up the latest values.
this._pushConfig();
this._proxyOwnerId = proxy.connect('notify::g-name-owner',
() => { if (proxy.g_name_owner) this._pushConfig(); });
}
);
} catch (e) {
console.error('[Strata] Failed to create D-Bus proxy:', e);
}
}
/** Read size limits from GSettings into instance fields (bytes). */
_readSizeLimits() {
this._maxHistory = this._settings.get_int('max-history');
this._maxTextBytes = this._settings.get_int('max-text-mb') * 1024 * 1024;
this._maxImageBytes = this._settings.get_int('max-image-mb') * 1024 * 1024;
}
/** Push runtime limits to the daemon. Safe to call before the proxy
* is ready or after the daemon has gone away. */
_pushConfig() {
this._proxy?.SetConfigRemote(
this._maxHistory,
this._maxTextBytes,
this._maxImageBytes,
() => {}
);
}
_connectSignals() {
this._itemAddedId = Gio.DBus.session.signal_subscribe(
BUS_NAME,
'org.gnome.Strata.Manager',
'ItemAdded',
OBJECT_PATH,
null,
Gio.DBusSignalFlags.NONE,
this._onItemAdded.bind(this)
);
this._itemDeletedId = Gio.DBus.session.signal_subscribe(
BUS_NAME,
'org.gnome.Strata.Manager',
'ItemDeleted',
OBJECT_PATH,
null,
Gio.DBusSignalFlags.NONE,
(_conn, _sender, _path, _iface, _signal, params) => {
const [id] = params.deepUnpack();
// Best-effort: unlink the on-disk thumbnail file (if any).
// GLib.unlink returns -1 if file doesn't exist; we ignore that.
try {
const cachePath =
`${GLib.get_user_cache_dir()}/strata/thumbnails/${id}.png`;
GLib.unlink(cachePath);
} catch (_) { /* not all items have thumbnails - fine */ }
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
this._panel?.removeItem(id);
return GLib.SOURCE_REMOVE;
});
}
);
this._historyClearedId = Gio.DBus.session.signal_subscribe(
BUS_NAME,
'org.gnome.Strata.Manager',
'HistoryCleared',
OBJECT_PATH,
null,
Gio.DBusSignalFlags.NONE,
() => {
// Wipe all on-disk thumbnails when daemon clears history.
try {
const dir = `${GLib.get_user_cache_dir()}/strata/thumbnails`;
const d = Gio.File.new_for_path(dir);
if (d.query_exists(null)) {
const en = d.enumerate_children(
'standard::name', Gio.FileQueryInfoFlags.NONE, null);
let info;
while ((info = en.next_file(null))) {
try { d.get_child(info.get_name()).delete(null); } catch (_) {}
}
en.close(null);
}
} catch (e) { console.error('[Strata] cache clear failed:', e); }
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
this._panel?.clearItems();
return GLib.SOURCE_REMOVE;
});
}
);
}
_disconnectSignals() {
if (this._itemAddedId !== null) {
Gio.DBus.session.signal_unsubscribe(this._itemAddedId);
this._itemAddedId = null;
}
if (this._itemDeletedId !== null) {
Gio.DBus.session.signal_unsubscribe(this._itemDeletedId);
this._itemDeletedId = null;
}
if (this._historyClearedId !== null) {
Gio.DBus.session.signal_unsubscribe(this._historyClearedId);
this._historyClearedId = null;
}
if (this._pendingSignalId !== null) {
GLib.Source.remove(this._pendingSignalId);
this._pendingSignalId = null;
}
}
_onItemAdded(_conn, _sender, _path, _iface, _signal, params) {
// Debounce: if the daemon emits a burst, coalesce into one update.
if (this._pendingSignalId !== null) {
GLib.Source.remove(this._pendingSignalId);
this._pendingSignalId = null;
}
this._pendingSignalId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 50, () => {
this._pendingSignalId = null;
this._processItemAdded(params).catch(e => console.error('[Strata] ItemAdded error:', e));
return GLib.SOURCE_REMOVE;
});
}
async _processItemAdded(params) {
if (this.#busy) return;
this.#busy = true;
try {
const [id, mimeType, preview] = params.deepUnpack();
// Exclusion check - no clipboard I/O, just string comparison.
if (this._isExcluded(this._currentFocusedApp)) {
try {
await this._proxy.DeleteItemAsync(id);
} catch (e) {
// ignore - item may already be gone
}
return;
}
// Defer UI mutation to after the current frame renders.
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
this._panel?.prependItem(id, mimeType, preview);
return GLib.SOURCE_REMOVE;
});
} finally {
this.#busy = false;
}
}
_isExcluded(appClass) {
if (!appClass) return false;
return this._excludedApps.some(ex => appClass.includes(ex.toLowerCase()));
}
_connectFocusTracking() {
this._focusSignalId = global.display.connect('notify::focus-window', () => {
const win = global.display.focus_window;
this._currentFocusedApp = (win?.get_wm_class() ?? '').toLowerCase();
// Inform the daemon (best-effort, ignore failures).
this._proxy?.SetFocusedAppRemote(this._currentFocusedApp, () => {});
});
}
_disconnectFocusTracking() {
if (this._focusSignalId !== null) {
global.display.disconnect(this._focusSignalId);
this._focusSignalId = null;
}
}
_registerShortcut() {
Main.wm.addKeybinding(
'keyboard-shortcut',
this._settings,
Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW,
() => this._panel?.toggle()
);
}
_unregisterShortcut() {
Main.wm.removeKeybinding('keyboard-shortcut');
}
}

View file

@ -0,0 +1,9 @@
{
"name": "Strata",
"description": "A fast, stutter-free clipboard manager. All I/O runs in a Rust daemon - GNOME Shell is never blocked.",
"uuid": "strata@edu4rdshl.dev",
"version": 2,
"shell-version": ["45", "46", "47", "48", "49", "50"],
"settings-schema": "org.gnome.shell.extensions.strata",
"url": "https://github.com/Edu4rdSHL/Strata"
}

View file

@ -0,0 +1,347 @@
/**
* prefs.js - Strata preferences window (GNOME 45+ / Adw).
*
* Pages:
* General: max-history SpinRow, keyboard-shortcut ShortcutRow
* Privacy: excluded-apps ExpanderRow + StringList (one entry per line)
*/
import Adw from 'gi://Adw';
import Gtk from 'gi://Gtk';
import Gdk from 'gi://Gdk';
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
export default class StrataPreferences extends ExtensionPreferences {
fillPreferencesWindow(window) {
const settings = this.getSettings();
window.add(this._buildGeneralPage(settings));
window.add(this._buildPrivacyPage(settings));
}
// -------------------------------------------------------------------------
// General page
// -------------------------------------------------------------------------
_buildGeneralPage(settings) {
const page = new Adw.PreferencesPage({
title: 'General',
icon_name: 'preferences-system-symbolic',
});
// ── History ──────────────────────────────────────────────────────────
const historyGroup = new Adw.PreferencesGroup({ title: 'History' });
const maxHistoryRow = new Adw.SpinRow({
title: 'Maximum items',
subtitle: 'All items are searchable and reachable; older are deleted past this limit',
adjustment: new Gtk.Adjustment({
lower: 50,
upper: 2000,
step_increment: 10,
page_increment: 50,
}),
});
settings.bind('max-history', maxHistoryRow, 'value', Gio.SettingsBindFlags.DEFAULT);
historyGroup.add(maxHistoryRow);
const pageSizeRow = new Adw.SpinRow({
title: 'Items per page',
subtitle: 'Rows fetched on open and on each scroll-to-bottom',
adjustment: new Gtk.Adjustment({
lower: 20,
upper: 200,
step_increment: 10,
page_increment: 50,
}),
});
settings.bind('page-size', pageSizeRow, 'value', Gio.SettingsBindFlags.DEFAULT);
historyGroup.add(pageSizeRow);
const maxTextRow = new Adw.SpinRow({
title: 'Maximum text size (MB)',
subtitle: 'Text payloads larger than this are not stored',
adjustment: new Gtk.Adjustment({
lower: 1,
upper: 100,
step_increment: 1,
page_increment: 5,
}),
});
settings.bind('max-text-mb', maxTextRow, 'value', Gio.SettingsBindFlags.DEFAULT);
historyGroup.add(maxTextRow);
const maxImageRow = new Adw.SpinRow({
title: 'Maximum image size (MB)',
subtitle: 'Image payloads larger than this are not stored',
adjustment: new Gtk.Adjustment({
lower: 1,
upper: 100,
step_increment: 1,
page_increment: 5,
}),
});
settings.bind('max-image-mb', maxImageRow, 'value', Gio.SettingsBindFlags.DEFAULT);
historyGroup.add(maxImageRow);
page.add(historyGroup);
// ── Appearance ───────────────────────────────────────────────────────
const appearanceGroup = new Adw.PreferencesGroup({ title: 'Appearance' });
const panelWidthRow = new Adw.SpinRow({
title: 'Panel width',
subtitle: 'Width of the clipboard panel in pixels',
adjustment: new Gtk.Adjustment({
lower: 200,
upper: 1200,
step_increment: 10,
page_increment: 50,
}),
});
settings.bind('panel-width', panelWidthRow, 'value', Gio.SettingsBindFlags.DEFAULT);
appearanceGroup.add(panelWidthRow);
const panelMaxHeightRow = new Adw.SpinRow({
title: 'Panel max height',
subtitle: 'Maximum height before the list scrolls',
adjustment: new Gtk.Adjustment({
lower: 200,
upper: 1200,
step_increment: 10,
page_increment: 50,
}),
});
settings.bind('panel-max-height', panelMaxHeightRow, 'value', Gio.SettingsBindFlags.DEFAULT);
appearanceGroup.add(panelMaxHeightRow);
const moveToTopRow = new Adw.SwitchRow({
title: 'Move activated item to top',
subtitle: 'Selecting an item moves it to the top of the list',
});
settings.bind('move-activated-to-top', moveToTopRow, 'active', Gio.SettingsBindFlags.DEFAULT);
appearanceGroup.add(moveToTopRow);
const positions = [
{ id: 'top-center', label: 'Top center' },
{ id: 'top-left', label: 'Top left' },
{ id: 'top-right', label: 'Top right' },
{ id: 'center', label: 'Center' },
{ id: 'bottom-center', label: 'Bottom center' },
{ id: 'bottom-left', label: 'Bottom left' },
{ id: 'bottom-right', label: 'Bottom right' },
];
const positionRow = new Adw.ComboRow({
title: 'Panel position',
subtitle: 'Where the clipboard panel appears on screen',
model: Gtk.StringList.new(positions.map(p => p.label)),
});
const currentPos = settings.get_string('panel-position');
const currentIdx = positions.findIndex(p => p.id === currentPos);
positionRow.selected = currentIdx >= 0 ? currentIdx : 0;
positionRow.connect('notify::selected', () => {
settings.set_string('panel-position', positions[positionRow.selected].id);
});
settings.connect('changed::panel-position', () => {
const idx = positions.findIndex(p => p.id === settings.get_string('panel-position'));
if (idx >= 0 && positionRow.selected !== idx)
positionRow.selected = idx;
});
appearanceGroup.add(positionRow);
page.add(appearanceGroup);
// ── Keyboard ─────────────────────────────────────────────────────────
const kbGroup = new Adw.PreferencesGroup({ title: 'Keyboard' });
const shortcutRow = new Adw.ActionRow({
title: 'Open Strata',
subtitle: 'Click to change the keyboard shortcut',
activatable: true,
});
const shortcutLabel = new Gtk.ShortcutLabel({
valign: Gtk.Align.CENTER,
disabled_text: 'Disabled',
});
const updateShortcutLabel = () => {
const shortcuts = settings.get_strv('keyboard-shortcut');
shortcutLabel.accelerator = shortcuts[0] ?? '';
};
updateShortcutLabel();
settings.connect('changed::keyboard-shortcut', updateShortcutLabel);
shortcutRow.add_suffix(shortcutLabel);
shortcutRow.connect('activated', () => {
this._showShortcutDialog(shortcutRow.get_root(), settings);
});
kbGroup.add(shortcutRow);
page.add(kbGroup);
return page;
}
// -------------------------------------------------------------------------
// Privacy page
// -------------------------------------------------------------------------
_buildPrivacyPage(settings) {
const page = new Adw.PreferencesPage({
title: 'Privacy',
icon_name: 'security-high-symbolic',
});
const group = new Adw.PreferencesGroup({
title: 'App Exclusions',
description: 'Items copied while these apps have focus will not be stored in history. Enter a partial app name (case-insensitive).',
});
// We use a StringList model bound to excluded-apps.
const model = new Gtk.StringList();
const currentApps = settings.get_strv('excluded-apps');
for (const app of currentApps)
model.append(app);
/** Sync the StringList back to GSettings. */
const saveModel = () => {
const apps = [];
for (let i = 0; i < model.get_n_items(); i++) {
const val = model.get_string(i);
if (val?.trim()) apps.push(val.trim());
}
settings.set_strv('excluded-apps', apps);
};
// Each item in the list: an EditableLabel + Remove button.
const listBox = new Gtk.ListBox({
selection_mode: Gtk.SelectionMode.NONE,
css_classes: ['boxed-list'],
});
const rebuildList = () => {
let child = listBox.get_first_child();
while (child) {
const next = child.get_next_sibling();
listBox.remove(child);
child = next;
}
for (let i = 0; i < model.get_n_items(); i++) {
const idx = i;
const row = new Adw.ActionRow({ activatable: false });
const label = new Gtk.EditableLabel({
text: model.get_string(i),
valign: Gtk.Align.CENTER,
hexpand: true,
});
label.connect('changed', () => {
model.splice(idx, 1, [label.text]);
saveModel();
});
const removeBtn = new Gtk.Button({
icon_name: 'list-remove-symbolic',
valign: Gtk.Align.CENTER,
css_classes: ['flat', 'destructive-action'],
tooltip_text: 'Remove',
});
removeBtn.connect('clicked', () => {
model.remove(idx);
saveModel();
rebuildList();
});
row.add_suffix(label);
row.add_suffix(removeBtn);
listBox.append(row);
}
// Add-new row.
const addRow = new Adw.ActionRow({ activatable: false });
const addEntry = new Gtk.Entry({
placeholder_text: 'App name…',
valign: Gtk.Align.CENTER,
hexpand: true,
});
const addBtn = new Gtk.Button({
icon_name: 'list-add-symbolic',
valign: Gtk.Align.CENTER,
css_classes: ['flat'],
tooltip_text: 'Add',
});
const doAdd = () => {
const val = addEntry.text.trim();
if (val) {
model.append(val);
saveModel();
rebuildList();
}
};
addBtn.connect('clicked', doAdd);
addEntry.connect('activate', doAdd);
addRow.add_suffix(addEntry);
addRow.add_suffix(addBtn);
listBox.append(addRow);
};
rebuildList();
group.add(listBox);
page.add(group);
return page;
}
// -------------------------------------------------------------------------
// Keyboard shortcut dialog
// -------------------------------------------------------------------------
_showShortcutDialog(parent, settings) {
const dialog = new Adw.MessageDialog({
heading: 'Set Keyboard Shortcut',
body: 'Press the desired key combination, or Backspace to clear.',
transient_for: parent,
modal: true,
});
dialog.add_response('cancel', 'Cancel');
dialog.add_response('clear', 'Clear');
const label = new Gtk.ShortcutLabel({
accelerator: settings.get_strv('keyboard-shortcut')[0] ?? '',
disabled_text: '(none)',
margin_top: 12,
margin_bottom: 12,
halign: Gtk.Align.CENTER,
});
dialog.set_extra_child(label);
const controller = new Gtk.EventControllerKey();
controller.connect('key-pressed', (_ctrl, keyval, keycode, state) => {
const mods = state & Gtk.accelerator_get_default_mod_mask();
if (keyval === Gdk.KEY_BackSpace) {
label.accelerator = '';
return true;
}
if (keyval === Gdk.KEY_Escape) {
dialog.close();
return true;
}
if (Gtk.accelerator_valid(keyval, mods)) {
label.accelerator = Gtk.accelerator_name(keyval, mods);
settings.set_strv('keyboard-shortcut',
label.accelerator ? [label.accelerator] : []);
dialog.close();
}
return true;
});
dialog.add_controller(controller);
dialog.connect('response', (_d, response) => {
if (response === 'clear') {
settings.set_strv('keyboard-shortcut', []);
}
dialog.destroy();
});
dialog.present();
}
}

View file

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<schemalist>
<schema id="org.gnome.shell.extensions.strata"
path="/org/gnome/shell/extensions/strata/">
<key name="max-history" type="i">
<default>200</default>
<range min="50" max="2000"/>
<summary>Maximum number of clipboard history items to keep</summary>
<description>All stored items are searchable (text only) and reachable via scroll. Higher values use more disk space (~30KB per image, ~1KB per text item).</description>
</key>
<key name="max-text-mb" type="i">
<default>1</default>
<range min="1" max="100"/>
<summary>Maximum text clipboard item size (MB)</summary>
<description>Text payloads larger than this are not stored.</description>
</key>
<key name="max-image-mb" type="i">
<default>5</default>
<range min="1" max="100"/>
<summary>Maximum image clipboard item size (MB)</summary>
<description>Image payloads larger than this are not stored.</description>
</key>
<key name="page-size" type="i">
<default>50</default>
<range min="20" max="200"/>
<summary>Number of items to fetch per history page</summary>
<description>The panel loads this many rows on open and again each time the scroll reaches the bottom.</description>
</key>
<key name="keyboard-shortcut" type="as">
<default><![CDATA[['<Super><Shift>v']]]></default>
<summary>Keyboard shortcut to open the clipboard panel</summary>
</key>
<key name="panel-width" type="i">
<default>480</default>
<summary>Width of the clipboard panel in pixels</summary>
</key>
<key name="panel-max-height" type="i">
<default>600</default>
<summary>Maximum height of the clipboard panel in pixels</summary>
</key>
<key name="move-activated-to-top" type="b">
<default>true</default>
<summary>Move the selected item to the top of the list when activated</summary>
<description>When enabled, clicking or pressing Enter on a history item moves it to position 1 in the list.</description>
</key>
<key name="panel-position" type="s">
<choices>
<choice value="top-center"/>
<choice value="top-left"/>
<choice value="top-right"/>
<choice value="center"/>
<choice value="bottom-center"/>
<choice value="bottom-left"/>
<choice value="bottom-right"/>
</choices>
<default>'top-center'</default>
<summary>Position of the clipboard panel on screen</summary>
</key>
<key name="excluded-apps" type="as">
<default><![CDATA[['1password', 'keepassxc', 'keepass', 'gnome-secrets', 'bitwarden', 'dashlane', 'lastpass', 'enpass']]]></default>
<summary>App WM class substrings to exclude from clipboard history</summary>
<description>Items copied while one of these apps has keyboard focus will not be stored in history. The match is case-insensitive and uses substring matching.</description>
</key>
</schema>
</schemalist>

View file

@ -0,0 +1,205 @@
/* Strata clipboard manager stylesheet.
*
* Performance rule: NO `transition: all`, NO Clutter/CSS animations.
* Only background-color transitions on hover (GPU-composited, zero JS).
* clip-to-allocation on the scroll view prevents overdraw outside the panel.
*/
/* ── Panel container ────────────────────────────────────────────────────── */
.strata-panel {
background-color: rgba(30, 30, 30, 0.97);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
padding: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
min-width: 360px;
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.strata-header {
margin-bottom: 8px;
}
.strata-title {
font-size: 1.1em;
font-weight: bold;
color: rgba(255, 255, 255, 0.9);
}
.strata-clear-btn {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: rgba(255, 255, 255, 0.55);
padding: 3px 10px;
font-size: 0.85em;
transition: background-color 100ms;
}
.strata-clear-btn:hover {
background-color: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
}
/* ── Search box ─────────────────────────────────────────────────────────── */
.strata-search {
background-color: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: rgba(255, 255, 255, 0.9);
padding: 6px 10px;
margin-bottom: 8px;
caret-color: rgba(255, 255, 255, 0.8);
}
.strata-search:focus {
border-color: rgba(99, 163, 255, 0.6);
background-color: rgba(255, 255, 255, 0.10);
}
/* ── Scroll view ────────────────────────────────────────────────────────── */
.strata-scroll {
clip-to-allocation: true;
}
.strata-item-list {
spacing: 2px;
}
/* ── Clipboard item ─────────────────────────────────────────────────────── */
.strata-item {
border-radius: 8px;
background-color: transparent;
padding: 0;
border: 1px solid transparent;
transition: background-color 100ms;
}
.strata-item:hover,
.strata-item-hovered {
background-color: rgba(80, 140, 255, 0.18);
border-color: rgba(80, 140, 255, 0.45);
}
/* Keyboard-focused item - clearly distinct from hover with accent blue.
* Both :focus (CSS pseudo) and .strata-item-focused (JS-added class) are used
* for belt-and-suspenders reliability across GNOME Shell versions. */
.strata-item:focus,
.strata-item-focused {
background-color: rgba(80, 140, 255, 0.18);
border-color: rgba(80, 140, 255, 0.60);
}
.strata-item:hover:focus,
.strata-item-focused.strata-item-hovered {
background-color: rgba(80, 140, 255, 0.28);
border-color: rgba(80, 140, 255, 0.70);
}
/* Bold the main text label when the item is focused.
* Also reinforced via JS key-focus-in signal in clipboardItem.js. */
.strata-item:focus .strata-item-text,
.strata-item-focused .strata-item-text {
font-weight: bold;
color: rgba(255, 255, 255, 1.0);
}
/* Active item - the most recently copied entry.
* Warm amber accent so it stands out from hover (white) and focus (blue). */
.strata-item-active {
background-color: rgba(255, 200, 80, 0.14);
border-color: rgba(255, 200, 80, 0.45);
}
.strata-item-active .strata-item-text {
color: rgba(255, 235, 160, 1.0);
}
.strata-item-active.strata-item-hovered {
background-color: rgba(255, 200, 80, 0.22);
}
/* Blue focus wins over amber active when navigating via keyboard. */
.strata-item-active.strata-item-focused,
.strata-item-active:focus {
background-color: rgba(80, 140, 255, 0.22);
border-color: rgba(80, 140, 255, 0.60);
}
.strata-item-active.strata-item-focused .strata-item-text,
.strata-item-active:focus .strata-item-text {
font-weight: bold;
color: rgba(255, 255, 255, 1.0);
}
.strata-item:active {
background-color: rgba(255, 255, 255, 0.14);
}
.strata-item-row {
padding: 8px 10px;
spacing: 10px;
}
/* ── Item icon ──────────────────────────────────────────────────────────── */
.strata-item-icon {
opacity: 0.6;
min-width: 20px;
}
/* ── Image thumbnail ────────────────────────────────────────────────────── */
.strata-item-thumb {
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* ── Color swatch ───────────────────────────────────────────────────────── */
.strata-item-swatch {
/* Inline style used for actual color - only shape/border set here. */
min-width: 24px;
min-height: 24px;
border-radius: 4px;
}
/* ── Text content ───────────────────────────────────────────────────────── */
.strata-item-content {
/* spacing between main and sub label */
spacing: 2px;
}
.strata-item-text {
color: rgba(255, 255, 255, 0.90);
font-size: 0.95em;
}
.strata-item-url {
color: rgba(99, 163, 255, 0.9);
}
.strata-item-subtext {
color: rgba(255, 255, 255, 0.45);
font-size: 0.78em;
}
/* ── Delete button ──────────────────────────────────────────────────────── */
.strata-item-delete {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.0);
padding: 4px;
border-radius: 6px;
transition: background-color 100ms, color 100ms;
}
/* Only show the delete icon on row hover or keyboard focus. */
.strata-item:hover .strata-item-delete,
.strata-item:focus .strata-item-delete,
.strata-item-focused .strata-item-delete,
.strata-item-hovered .strata-item-delete {
color: rgba(255, 80, 80, 0.7);
}
.strata-item-delete:hover {
background-color: rgba(255, 80, 80, 0.15);
color: rgba(255, 80, 80, 1.0);
}

View file

@ -0,0 +1,267 @@
/* clipboardItem.js - row widget for the clipboard list. */
import GObject from 'gi://GObject';
import GLib from 'gi://GLib';
import St from 'gi://St';
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
const TEXT_PREVIEW_LEN = 140;
const THUMB_SIZE = 48;
const ICON_BY_MIME = {
'text/uri-list': 'emblem-web-symbolic',
'text/html': 'text-html-symbolic',
'image/': 'image-x-generic-symbolic',
};
function iconForMime(mimeType) {
for (const [prefix, icon] of Object.entries(ICON_BY_MIME)) {
if (mimeType.startsWith(prefix)) return icon;
}
return 'edit-copy-symbolic';
}
/** Detect if a string looks like a URL. */
function isUrl(text) {
return /^https?:\/\/.+/i.test(text.trim());
}
/** Detect if a string looks like a CSS/hex color. */
function isColor(text) {
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(text.trim());
}
export const ClipboardItem = GObject.registerClass({
Signals: {
'activate': {},
'delete': {},
},
}, class ClipboardItem extends St.Button {
_init(id, mimeType, preview, opts = {}) {
super._init({
style_class: 'strata-item',
x_expand: true,
can_focus: true,
reactive: true,
});
this._id = id;
this._mimeType = mimeType;
/** D-Bus proxy used for lazy thumbnail fetch. */
this._proxy = opts.proxy ?? null;
/** Optional shared LRU cache (Map<id, string filePath>) - populated on first fetch. */
this._thumbCache = opts.thumbCache ?? null;
/** Stored on the actor so panel.js can filter by it. */
this.actor = this;
this._strataPreview = mimeType.startsWith('image/') ? '' : preview;
const row = new St.BoxLayout({
style_class: 'strata-item-row',
x_expand: true,
});
// Left: icon or image thumbnail
row.add_child(this._buildLeading(mimeType, preview));
// Center: text preview
row.add_child(this._buildContent(mimeType, preview));
// Right: delete button
const deleteBtn = new St.Button({
style_class: 'strata-item-delete',
icon_name: 'edit-delete-symbolic',
y_align: Clutter.ActorAlign.CENTER,
});
deleteBtn.connect('clicked', () => {
this.emit('delete');
return Clutter.EVENT_STOP;
});
row.add_child(deleteBtn);
this.set_child(row);
this.connect('clicked', () => this.emit('activate'));
// Make the item text bold when keyboard-focused so the selected item
// is immediately obvious during arrow-key navigation.
// We add/remove an explicit style class because CSS :focus pseudo-class
// can be unreliable in GNOME Shell extensions.
this.connect('key-focus-in', () => {
this.add_style_class_name('strata-item-focused');
if (this._mainLabel)
this._mainLabel.style = 'font-weight: bold; color: rgba(255, 255, 255, 1.0);';
});
this.connect('key-focus-out', () => {
this.remove_style_class_name('strata-item-focused');
if (this._mainLabel)
this._mainLabel.style = null;
});
}
_buildLeading(mimeType, preview) {
if (mimeType.startsWith('image/')) {
return this._buildThumbnail(this._id);
}
if (isColor(preview)) {
return this._buildColorSwatch(preview);
}
const icon = new St.Icon({
icon_name: iconForMime(mimeType),
icon_size: 20,
style_class: 'strata-item-icon',
y_align: Clutter.ActorAlign.CENTER,
});
return icon;
}
/** Build an image thumbnail container.
* - If already in shared cache, apply immediately.
* - Else fetch via GetThumbnailRemote, write to ~/.cache/strata/thumbnails/{id}.png,
* then apply. The decode happens off-thread (CSS background-image loader). */
_buildThumbnail(id) {
const container = new St.Widget({
width: THUMB_SIZE,
height: THUMB_SIZE,
style_class: 'strata-item-thumb',
y_align: Clutter.ActorAlign.CENTER,
});
const cacheDir = `${GLib.get_user_cache_dir()}/strata/thumbnails`;
const cachePath = `${cacheDir}/${id}.png`;
const fileUri = `file://${cachePath}`;
const applyStyle = () => {
try {
container.style = `background-image: url("${fileUri}"); background-size: cover; background-repeat: no-repeat;`;
} catch (_) { /* container was destroyed mid-flight */ }
};
try {
// Fast path: already cached this session.
if (this._thumbCache?.has(id)) {
applyStyle();
return container;
}
// Second fast path: file exists on disk from a previous session.
if (GLib.file_test(cachePath, GLib.FileTest.EXISTS)) {
this._thumbCache?.set(id, cachePath);
applyStyle();
return container;
}
// Slow path: ask the daemon for the bytes, then write file async.
GLib.mkdir_with_parents(cacheDir, 0o755);
if (!this._proxy) {
this._fallbackIcon(container);
return container;
}
this._proxy.GetThumbnailRemote(id, ([bytes]) => {
try {
if (!container.get_parent()) return; // destroyed
if (!bytes || bytes.length === 0) {
this._fallbackIcon(container);
return;
}
const file = Gio.File.new_for_path(cachePath);
file.replace_contents_bytes_async(
new GLib.Bytes(bytes),
null,
false,
Gio.FileCreateFlags.NONE,
null,
(_f, result) => {
try {
_f.replace_contents_finish(result);
this._thumbCache?.set(id, cachePath);
applyStyle();
} catch (e) {
console.error('[Strata] Thumbnail write error:', e);
this._fallbackIcon(container);
}
}
);
} catch (e) {
console.error('[Strata] Thumbnail fetch handler error:', e);
this._fallbackIcon(container);
}
});
} catch (e) {
console.error('[Strata] Thumbnail render error:', e);
this._fallbackIcon(container);
}
return container;
}
_fallbackIcon(container) {
if (container.get_n_children() > 0) return;
try {
const icon = new St.Icon({
icon_name: 'image-x-generic-symbolic',
icon_size: 20,
y_align: Clutter.ActorAlign.CENTER,
x_align: Clutter.ActorAlign.CENTER,
});
container.add_child(icon);
} catch (_) {}
}
_buildColorSwatch(hex) {
return new St.Widget({
width: 24,
height: 24,
style: `background-color: ${hex}; border-radius: 4px; border: 1px solid rgba(0,0,0,0.2);`,
style_class: 'strata-item-swatch',
y_align: Clutter.ActorAlign.CENTER,
});
}
_buildContent(mimeType, preview) {
preview = preview ?? '';
const box = new St.BoxLayout({
vertical: true,
x_expand: true,
style_class: 'strata-item-content',
});
let mainText = '';
let subText = '';
if (mimeType.startsWith('image/')) {
mainText = mimeType === 'image/png' ? 'PNG image' : 'Image';
} else if (isUrl(preview)) {
mainText = preview.trim();
try {
subText = new URL(preview.trim()).hostname;
} catch (_) {}
} else if (isColor(preview)) {
mainText = preview.trim().toUpperCase();
subText = 'Color';
} else {
const trimmed = preview.replace(/\s+/g, ' ').trim();
mainText = trimmed.length > TEXT_PREVIEW_LEN
? trimmed.slice(0, TEXT_PREVIEW_LEN) + '…'
: trimmed;
}
const labelMain = new St.Label({
text: mainText || '(empty)',
style_class: `strata-item-text${isUrl(preview) ? ' strata-item-url' : ''}`,
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
labelMain.clutter_text.line_wrap = false;
labelMain.clutter_text.ellipsize = 3; // PANGO_ELLIPSIZE_END
this._mainLabel = labelMain; // held for key-focus bold styling
box.add_child(labelMain);
if (subText) {
const labelSub = new St.Label({
text: subText,
style_class: 'strata-item-subtext',
x_expand: true,
});
box.add_child(labelSub);
}
return box;
}
});

View file

@ -0,0 +1,669 @@
/* panel.js - Strata clipboard popup panel. */
import GLib from 'gi://GLib';
import St from 'gi://St';
import Clutter from 'gi://Clutter';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import { ClipboardItem } from './clipboardItem.js';
const SEARCH_LIMIT = 500;
const SEARCH_DEBOUNCE_MS = 150;
const LOAD_MORE_THRESHOLD = 200;
export class StrataPanel {
constructor(proxy, settings, indicator = null) {
this._proxy = proxy;
this._settings = settings;
this._indicator = indicator; // PanelMenu.Button - used to avoid toggle-reopen race
this._pageSize = settings.get_int('page-size');
this._pageSizeChangedId = settings.connect('changed::page-size',
() => { this._pageSize = settings.get_int('page-size'); });
/** @type {{ id: string, mimeType: string, preview: string }[]} */
this._items = [];
/** @type {Map<string, ClipboardItem>} id → widget */
this._widgets = new Map();
/** @type {Map<string, string>} id → cache file path (shared across widget lifetimes) */
this._thumbCache = new Map();
/** Pagination state for the non-search view. */
this._loadedOffset = 0; // how many items we've already pulled
this._hasMore = true; // false once daemon returns < PAGE_SIZE
this._loadingMore = false; // re-entrancy guard for scroll-driven loads
/** Search state. */
this._searchQuery = ''; // current search string ('' = no search)
this._searchDebounceId = null; // pending GLib timeout for debounce
this._searchEpoch = 0; // monotonic counter to discard stale search responses
this._visible = false;
this._grab = null; // Clutter.Grab from Main.pushModal
this._hoveredWidget = null; // currently mouse-hovered ClipboardItem
this._activeWidget = null; // most recently copied item
this._buildUI();
// Trigger the initial load as soon as the daemon owns its bus name.
// At extension boot the daemon may still be starting (its D-Bus name
// not yet owned); the proxy fires notify::g-name-owner when that
// changes, so we listen for the first transition to a non-null owner
// and only then issue GetHistory. If the daemon is already up, the
// owner is already set and we load immediately.
this._tryInitialLoad();
this._nameOwnerId = this._proxy.connect('notify::g-name-owner',
() => this._tryInitialLoad());
}
_tryInitialLoad() {
if (this._initialLoaded) return;
if (!this._proxy.g_name_owner) return;
this._initialLoaded = true;
this._loadHistory(0, this._pageSize).catch(e =>
console.error('[Strata] initial _loadHistory failed:', e));
}
_buildUI() {
// Overlay container - sits above all windows.
this._overlay = new St.Widget({
layout_manager: new Clutter.FixedLayout(),
visible: false,
reactive: true,
});
Main.layoutManager.addChrome(this._overlay);
// Close when clicking outside the panel box.
// With pushModal active, ALL pointer events are delivered to _overlay,
// so this handler sees every click on screen.
this._overlay.connect('button-press-event', (_actor, event) => {
const [cx, cy] = event.get_coords();
const [bx, by] = this._box.get_transformed_position();
const [bw, bh] = this._box.get_transformed_size();
if (cx >= bx && cx <= bx + bw && cy >= by && cy <= by + bh)
return Clutter.EVENT_PROPAGATE;
this.close();
return Clutter.EVENT_STOP;
});
// pushModal intercepts all motion events at the overlay level, so CSS
// :hover never fires on child actors. Manually track which item is
// under the cursor and toggle the JS-driven 'strata-item-hovered' class.
this._overlay.connect('motion-event', (_actor, event) => {
const [cx, cy] = event.get_coords();
let found = null;
for (const widget of this._widgets.values()) {
if (!widget.visible) continue;
const [wx, wy] = widget.get_transformed_position();
const [ww, wh] = widget.get_transformed_size();
if (cx >= wx && cx <= wx + ww && cy >= wy && cy <= wy + wh) {
found = widget;
break;
}
}
if (found !== this._hoveredWidget) {
this._hoveredWidget?.remove_style_class_name('strata-item-hovered');
this._hoveredWidget = found;
found?.add_style_class_name('strata-item-hovered');
}
return Clutter.EVENT_PROPAGATE;
});
this._overlay.connect('leave-event', () => {
this._hoveredWidget?.remove_style_class_name('strata-item-hovered');
this._hoveredWidget = null;
return Clutter.EVENT_PROPAGATE;
});
// Panel box
this._box = new St.BoxLayout({
style_class: 'strata-panel',
vertical: true,
reactive: true,
});
// Header row: title + clear button
const header = new St.BoxLayout({
style_class: 'strata-header',
x_expand: true,
});
const title = new St.Label({
text: 'Clipboard',
style_class: 'strata-title',
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
const clearBtn = new St.Button({
label: 'Clear all',
style_class: 'strata-clear-btn',
y_align: Clutter.ActorAlign.CENTER,
});
clearBtn.connect('clicked', () => this._clearAll());
header.add_child(title);
header.add_child(clearBtn);
// Search box
this._searchEntry = new St.Entry({
hint_text: 'Search…',
style_class: 'strata-search',
x_expand: true,
can_focus: true,
});
this._searchEntry.get_clutter_text().connect('text-changed', () => {
this._scheduleSearch(this._searchEntry.get_text());
});
// Down arrow from search box moves focus to the first item.
this._searchEntry.get_clutter_text().connect('key-press-event', (_actor, event) => {
if (event.get_key_symbol() === Clutter.KEY_Down) {
this._focusItem(0);
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
});
// Item list
this._scrollView = new St.ScrollView({
style_class: 'strata-scroll',
x_expand: true,
y_expand: true,
overlay_scrollbars: true,
});
this._itemList = new St.BoxLayout({
style_class: 'strata-item-list',
vertical: true,
x_expand: true,
});
this._scrollView.set_child(this._itemList);
// Load more items when scrolled near the bottom.
const vadj = this._scrollView.get_vadjustment();
if (vadj) {
vadj.connect('notify::value', () => this._maybeLoadMore());
// Also re-check when the list grows (a new page just appended).
vadj.connect('notify::upper', () => this._maybeLoadMore());
}
// Assemble
this._box.add_child(header);
this._box.add_child(this._searchEntry);
this._box.add_child(this._scrollView);
this._overlay.add_child(this._box);
// ESC to close
this._overlay.connect('key-press-event', (_actor, event) => {
if (event.get_key_symbol() === Clutter.KEY_Escape) {
this.close();
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
});
}
toggle() {
this._visible ? this.close() : this.open();
}
open() {
if (this._visible) return;
this._visible = true;
this._overlay.show();
this._positionPanel();
// Re-position after one frame so bottom/center use the fully allocated height.
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
if (this._visible) this._positionPanel();
return GLib.SOURCE_REMOVE;
});
// pushModal grabs ALL input and delivers it to _overlay - this is the
// proper GNOME Shell pattern for "click outside to close" dialogs.
this._grab = Main.pushModal(this._overlay, {
actionMode: Shell.ActionMode.POPUP,
});
this._searchEntry.set_text('');
// Reset to non-search view - but DON'T reload; history may already be populated.
this._searchQuery = '';
// If the initial history load failed (e.g. daemon wasn't ready at startup),
// retry now that the user is asking to see the list.
if (this._items.length === 0 && this._loadedOffset === 0 && this._hasMore) {
this._loadHistory(0, this._pageSize).catch(e =>
console.error('[Strata] open: history reload failed:', e));
}
global.stage.set_key_focus(this._searchEntry.get_clutter_text());
}
close() {
if (!this._visible) return;
this._visible = false;
// Clear hover state since the panel is closing.
this._hoveredWidget?.remove_style_class_name('strata-item-hovered');
this._hoveredWidget = null;
if (this._grab) {
Main.popModal(this._grab);
this._grab = null;
}
this._overlay.hide();
}
/** Prepend a newly added item. Wrapped in try/catch so one bad item can't
* break subsequent additions. */
prependItem(id, mimeType, preview) {
try {
this._items.unshift({ id, mimeType, preview });
this._loadedOffset += 1;
if (this._widgets.has(id)) {
this._widgets.get(id)?.destroy();
this._widgets.delete(id);
}
// While searching, only render if the new item matches the active query.
if (this._searchQuery && !this._matchesSearch(preview, mimeType)) {
return;
}
const widget = this._makeItemWidget(id, mimeType, preview);
this._widgets.set(id, widget);
this._itemList.insert_child_at_index(widget.actor, 0);
this._setActiveWidget(widget);
} catch (e) {
console.error(`[Strata] prependItem failed for id=${id} mime=${mimeType}:`, e);
this._items = this._items.filter(i => i.id !== id);
}
}
// Substring match used for newly-arrived items while a search is active.
// Approximates FTS5 prefix match until the next debounced search refresh.
_matchesSearch(preview, mimeType) {
if (mimeType?.startsWith('image/')) return false;
if (!preview) return false;
const haystack = preview.toLowerCase();
return this._searchQuery.toLowerCase().split(/\s+/)
.filter(Boolean).every(tok => haystack.includes(tok));
}
removeItem(id) {
this._items = this._items.filter(i => i.id !== id);
const widget = this._widgets.get(id);
if (widget) {
const wasActive = widget === this._activeWidget;
const wasHovered = widget === this._hoveredWidget;
if (wasActive) this._activeWidget = null;
if (wasHovered) this._hoveredWidget = null;
widget.destroy();
this._widgets.delete(id);
// Re-assign active to the new first visible item.
if (wasActive) {
const first = this._getVisibleItems()[0];
if (first) this._setActiveWidget(first);
}
}
}
clearItems() {
this._items = [];
this._hoveredWidget = null;
this._activeWidget = null;
this._widgets.forEach(w => w.destroy());
this._widgets.clear();
this._itemList.destroy_all_children();
}
destroy() {
if (this._pageSizeChangedId) {
this._settings.disconnect(this._pageSizeChangedId);
this._pageSizeChangedId = 0;
}
if (this._nameOwnerId) {
this._proxy.disconnect(this._nameOwnerId);
this._nameOwnerId = 0;
}
if (this._initialLoadId) {
GLib.Source.remove(this._initialLoadId);
this._initialLoadId = null;
}
if (this._searchDebounceId) {
GLib.Source.remove(this._searchDebounceId);
this._searchDebounceId = null;
}
this.close();
this.clearItems();
Main.layoutManager.removeChrome(this._overlay);
this._overlay.destroy();
this._overlay = null;
}
async _loadHistory(offset, limit) {
if (!this._overlay) return 0;
try {
const [json] = await this._proxy.GetHistoryAsync(offset, limit);
if (!this._overlay) return 0;
const items = JSON.parse(json);
const BATCH = 20;
for (let i = 0; i < items.length; i += BATCH) {
if (!this._overlay) return items.length;
await new Promise(resolve =>
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
const end = Math.min(i + BATCH, items.length);
for (let j = i; j < end; j++) this._appendItemFromMeta(items[j]);
resolve();
return GLib.SOURCE_REMOVE;
})
);
}
this._loadedOffset = offset + items.length;
this._hasMore = items.length >= limit;
if (offset === 0 && items.length > 0) {
const firstWidget = this._widgets.get(items[0].id);
if (firstWidget) this._setActiveWidget(firstWidget);
}
return items.length;
} catch (e) {
console.error('[Strata] _loadHistory failed:', e);
return 0;
}
}
_appendItemFromMeta(meta) {
try {
const id = meta.id;
const mimeType = meta.mime_type;
const preview = meta.content_text ?? '';
if (this._widgets.has(id)) return;
this._items.push({ id, mimeType, preview });
const widget = this._makeItemWidget(id, mimeType, preview);
this._widgets.set(id, widget);
this._itemList.add_child(widget.actor);
} catch (e) {
console.error(`[Strata] _appendItemFromMeta failed for id=${meta?.id}:`, e);
}
}
_maybeLoadMore() {
if (this._searchQuery) return;
if (!this._hasMore || this._loadingMore) return;
const adj = this._scrollView.get_vadjustment();
if (!adj || adj.upper <= adj.page_size) return;
const distanceToBottom = adj.upper - (adj.value + adj.page_size);
if (distanceToBottom > LOAD_MORE_THRESHOLD) return;
this._loadingMore = true;
this._loadHistory(this._loadedOffset, this._pageSize)
.finally(() => { this._loadingMore = false; });
}
_makeItemWidget(id, mimeType, preview) {
const widget = new ClipboardItem(id, mimeType, preview, {
proxy: this._proxy,
thumbCache: this._thumbCache,
});
widget.connect('activate', () => {
this._setActiveWidget(widget);
if (this._settings?.get_boolean('move-activated-to-top'))
this._moveItemToTop(id, widget);
this._onItemActivated(id);
});
widget.connect('delete', () => {
this._proxy.DeleteItemRemote(id, () => {});
});
widget.connect('key-press-event', (_actor, event) => {
const sym = event.get_key_symbol();
const items = this._getVisibleItems();
const idx = items.indexOf(widget);
if (sym === Clutter.KEY_Down) {
if (idx < items.length - 1) {
global.stage.set_key_focus(items[idx + 1]);
this._ensureVisible(items[idx + 1]);
}
return Clutter.EVENT_STOP;
}
if (sym === Clutter.KEY_Up) {
if (idx <= 0) {
global.stage.set_key_focus(this._searchEntry.get_clutter_text());
} else {
global.stage.set_key_focus(items[idx - 1]);
this._ensureVisible(items[idx - 1]);
}
return Clutter.EVENT_STOP;
}
if (sym === Clutter.KEY_Escape) {
this.close();
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
});
return widget;
}
/** Focus the item at visible-list index `idx` (clamped). */
_focusItem(idx) {
const items = this._getVisibleItems();
if (items.length === 0) return;
global.stage.set_key_focus(items[Math.max(0, Math.min(idx, items.length - 1))]);
}
/** All currently-visible item actors in order. */
_getVisibleItems() {
return this._itemList.get_children().filter(c => c.visible);
}
_ensureVisible(widget) {
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
try {
const adj = this._scrollView.get_vadjustment();
if (!adj || adj.page_size === 0) return GLib.SOURCE_REMOVE;
// Allocation box coords share the same space as adj.value.
const box = widget.get_allocation_box();
const cur = adj.value;
const pageSize = adj.page_size;
if (box.y1 < cur)
adj.value = box.y1;
else if (box.y2 > cur + pageSize)
adj.value = box.y2 - pageSize;
} catch (e) {
console.error('[Strata] ensureVisible error:', e);
}
return GLib.SOURCE_REMOVE;
});
}
async _onItemActivated(id) {
try {
const [mimeType, contentB64] = await this._proxy.GetItemContentAsync(id);
this._writeToClipboard(mimeType, GLib.base64_decode(contentB64));
} catch (e) {
console.error('[Strata] Paste error:', e);
}
this.close();
}
_writeToClipboard(mimeType, bytes) {
try {
if (mimeType.startsWith('text/') || mimeType === 'UTF8_STRING') {
const text = new TextDecoder('utf-8').decode(bytes);
St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, text);
} else {
const source = Meta.SelectionSourceMemory.new(
mimeType,
GLib.Bytes.new(bytes)
);
global.display.get_selection().set_owner(
Meta.SelectionType.SELECTION_CLIPBOARD,
source
);
}
} catch (e) {
console.error('[Strata] Clipboard write error:', e);
}
}
async _clearAll() {
try {
await this._proxy.ClearHistoryAsync();
} catch (e) {
console.error('[Strata] ClearHistory error:', e);
}
}
/** Debounce search box keystrokes; collapse rapid edits into one query. */
_scheduleSearch(query) {
if (this._searchDebounceId) {
GLib.Source.remove(this._searchDebounceId);
this._searchDebounceId = null;
}
const trimmed = (query ?? '').trim();
this._searchDebounceId = GLib.timeout_add(
GLib.PRIORITY_DEFAULT, SEARCH_DEBOUNCE_MS, () => {
this._searchDebounceId = null;
this._runSearch(trimmed).catch(e =>
console.error('[Strata] search failed:', e));
return GLib.SOURCE_REMOVE;
});
}
/** Apply a search query (empty = reset to the recent-history view).
* All state-mutating writes are guarded with an epoch so a fast-cancelled
* search cannot overwrite the results of a newer one. */
async _runSearch(query) {
const epoch = ++this._searchEpoch;
if (!query) {
this._searchQuery = '';
this._clearListDom();
this._items = [];
this._loadedOffset = 0;
this._hasMore = true;
// Don't await: if the user starts a new search mid-load, the epoch
// guard inside _appendItemFromMeta's caller would still let stale
// rows leak in. So check the epoch before letting any rows in.
const ePromise = this._loadHistory(0, this._pageSize);
ePromise.then(() => {
if (epoch !== this._searchEpoch) {
// A newer search superseded us; nuke whatever we appended.
this._clearListDom();
this._items = [];
}
}).catch(e => console.error('[Strata] reset-history failed:', e));
return;
}
this._searchQuery = query;
let json;
try {
[json] = await this._proxy.SearchHistoryAsync(query, SEARCH_LIMIT);
} catch (e) {
console.error('[Strata] SearchHistory D-Bus error:', e);
return;
}
if (epoch !== this._searchEpoch || !this._overlay) return; // stale or destroyed
let results;
try {
results = JSON.parse(json);
} catch (e) {
console.error('[Strata] SearchHistory: bad JSON:', e);
return;
}
this._clearListDom();
this._items = [];
// Disable pagination while searching - the search response is already
// bounded by SEARCH_LIMIT, scrolling shouldn't pull more.
this._hasMore = false;
this._loadedOffset = 0;
const BATCH = 20;
for (let i = 0; i < results.length; i += BATCH) {
if (epoch !== this._searchEpoch || !this._overlay) return;
await new Promise(resolve =>
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
const end = Math.min(i + BATCH, results.length);
for (let j = i; j < end; j++) this._appendItemFromMeta(results[j]);
resolve();
return GLib.SOURCE_REMOVE;
}));
}
}
/** Tear down all rendered item widgets (without touching the data model). */
_clearListDom() {
this._hoveredWidget = null;
this._activeWidget = null;
this._widgets.forEach(w => w.destroy());
this._widgets.clear();
this._itemList.destroy_all_children();
}
_setActiveWidget(widget) {
this._activeWidget?.remove_style_class_name('strata-item-active');
this._activeWidget = widget ?? null;
widget?.add_style_class_name('strata-item-active');
}
_moveItemToTop(id, widget) {
// Move data model entry to front.
const idx = this._items.findIndex(i => i.id === id);
if (idx > 0) {
const [entry] = this._items.splice(idx, 1);
this._items.unshift(entry);
}
// Move actor to position 0 in the list.
this._itemList.set_child_at_index(widget, 0);
}
_positionPanel() {
const monitor = Main.layoutManager.primaryMonitor;
if (!monitor) return;
const PANEL_W = Math.min(
this._settings?.get_int('panel-width') ?? 480,
monitor.width * 0.9
);
const MAX_H = Math.min(
this._settings?.get_int('panel-max-height') ?? 600,
monitor.height * 0.85
);
this._box.set_width(PANEL_W);
// Let height shrink to content; cap at MAX_H so the scroll view kicks in
// when there are many items.
this._box.set_height(-1);
this._box.style = `max-height: ${MAX_H}px;`;
const position = this._settings?.get_string('panel-position') ?? 'top-center';
const MARGIN = 16; // px gap from screen edge
let x, y;
// Horizontal
if (position.endsWith('left'))
x = monitor.x + MARGIN;
else if (position.endsWith('right'))
x = monitor.x + monitor.width - PANEL_W - MARGIN;
else // center
x = monitor.x + Math.round((monitor.width - PANEL_W) / 2);
// Vertical - snap to shell bars so the panel feels native
const topBarH = Main.layoutManager.panelBox?.height ?? 32;
if (position.startsWith('top')) {
y = monitor.y + topBarH + MARGIN;
} else {
// For bottom/center we need the actual rendered height, not MAX_H.
// get_preferred_height(-1) returns [minH, naturalH] synchronously.
const [, naturalH] = this._box.get_preferred_height(-1);
const actualH = Math.min(naturalH, MAX_H);
if (position.startsWith('bottom'))
y = monitor.y + monitor.height - actualH - MARGIN;
else // center
y = monitor.y + Math.round((monitor.height - actualH) / 2);
}
this._box.set_position(Math.round(x), Math.round(y));
// Overlay fills the screen for click-outside detection.
this._overlay.set_position(monitor.x, monitor.y);
this._overlay.set_size(monitor.width, monitor.height);
}
}