commit 350d6479536cda08f39793601bb7344aa341266e Author: edu4rdshl Date: Mon May 25 03:02:34 2026 -0500 Initial commit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8d429e2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9bf371 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..af1f622 --- /dev/null +++ b/AGENTS.md @@ -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` 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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100755 index 0000000..9e914e7 --- /dev/null +++ b/ARCHITECTURE.md @@ -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) + | + +-- 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/.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/.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). diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0b80078 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4780da6 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ece76e --- /dev/null +++ b/README.md @@ -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 45–49 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. diff --git a/contrib/systemd/strata-daemon.service b/contrib/systemd/strata-daemon.service new file mode 100644 index 0000000..423e400 --- /dev/null +++ b/contrib/systemd/strata-daemon.service @@ -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 diff --git a/strata-daemon/Cargo.lock b/strata-daemon/Cargo.lock new file mode 100644 index 0000000..9464eb4 --- /dev/null +++ b/strata-daemon/Cargo.lock @@ -0,0 +1,2170 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strata-daemon" +version = "0.2.0" +dependencies = [ + "anyhow", + "base64", + "blake3", + "dirs", + "image", + "rusqlite", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", + "wl-clipboard-rs", + "zbus", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tokio", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/strata-daemon/Cargo.toml b/strata-daemon/Cargo.toml new file mode 100644 index 0000000..8786467 --- /dev/null +++ b/strata-daemon/Cargo.toml @@ -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 diff --git a/strata-daemon/README.md b/strata-daemon/README.md new file mode 100644 index 0000000..0520de0 --- /dev/null +++ b/strata-daemon/README.md @@ -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/.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. diff --git a/strata-daemon/src/clipboard/mod.rs b/strata-daemon/src/clipboard/mod.rs new file mode 100644 index 0000000..d3f3348 --- /dev/null +++ b/strata-daemon/src/clipboard/mod.rs @@ -0,0 +1,2 @@ +pub mod monitor; +pub mod writer; diff --git a/strata-daemon/src/clipboard/monitor.rs b/strata-daemon/src/clipboard/monitor.rs new file mode 100644 index 0000000..4d9f72e --- /dev/null +++ b/strata-daemon/src/clipboard/monitor.rs @@ -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, +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +struct MonitorState { + /// Pending offers keyed by Wayland object ID. + /// Each entry holds the offered MIME types as they arrive. + offers: HashMap>, + tx: UnboundedSender, +} + +impl MonitorState { + fn new(tx: UnboundedSender) -> 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 for MonitorState { + fn event( + _state: &mut Self, + _proxy: &wl_registry::WlRegistry, + _event: wl_registry::Event, + _data: &GlobalListContents, + _conn: &Connection, + _qh: &QueueHandle, + ) { + // Handled by GlobalList inside registry_queue_init. + } +} + +// --------------------------------------------------------------------------- +// ext-data-control-v1 +// --------------------------------------------------------------------------- + +impl Dispatch for MonitorState { + fn event( + _: &mut Self, + _: &ExtDataControlManagerV1, + _: ext_data_control_manager_v1::Event, // manager has no events - unreachable + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for MonitorState { + fn event( + state: &mut Self, + _device: &ExtDataControlDeviceV1, + event: ext_data_control_device_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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 for MonitorState { + fn event( + state: &mut Self, + proxy: &ExtDataControlOfferV1, + event: ext_data_control_offer_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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 for MonitorState { + fn event( + _: &mut Self, + _: &ZwlrDataControlManagerV1, + _: zwlr_data_control_manager_v1::Event, // manager has no events - unreachable + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for MonitorState { + fn event( + state: &mut Self, + _device: &ZwlrDataControlDeviceV1, + event: zwlr_data_control_device_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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 for MonitorState { + fn event( + state: &mut Self, + proxy: &ZwlrDataControlOfferV1, + event: zwlr_data_control_offer_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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) -> 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 { + // A quick roundtrip to get the global list; no persistent state needed here. + let (globals, _) = registry_queue_init::(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, +) -> Result<()> { + let (globals, mut queue) = registry_queue_init::(&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(()) +} diff --git a/strata-daemon/src/clipboard/writer.rs b/strata-daemon/src/clipboard/writer.rs new file mode 100644 index 0000000..0d99c32 --- /dev/null +++ b/strata-daemon/src/clipboard/writer.rs @@ -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, +} + +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 = 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(()) +} diff --git a/strata-daemon/src/config.rs b/strata-daemon/src/config.rs new file mode 100644 index 0000000..f18345c --- /dev/null +++ b/strata-daemon/src/config.rs @@ -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, + } + } +} diff --git a/strata-daemon/src/db.rs b/strata-daemon/src/db.rs new file mode 100644 index 0000000..df72e1f --- /dev/null +++ b/strata-daemon/src/db.rs @@ -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, + pub source_app: Option, + 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, + pub content_blob: Option>, + pub thumbnail_blob: Option>, + pub source_app: Option, + pub created_at: i64, +} + +pub struct Db { + conn: Mutex, +} + +/// 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) -> 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 { + 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 = 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> { + 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::>>()?; + + 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> { + let conn = lock_conn(&self.conn); + + // Build a safe FTS5 prefix query: each token quoted + '*' suffix for prefix match. + let tokens: Vec = 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::>>()?; + + 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>> { + let conn = lock_conn(&self.conn); + let blob: Option>> = 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> { + 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 { + 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> { + 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 = stmt + .query_map(params![max_history as i64], |row| row.get::<_, String>(0))? + .collect::>>()?; + 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 +} diff --git a/strata-daemon/src/dbus_service.rs b/strata-daemon/src/dbus_service.rs new file mode 100644 index 0000000..cea4c11 --- /dev/null +++ b/strata-daemon/src/dbus_service.rs @@ -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, + pub max_text_bytes: Arc, + pub max_image_bytes: Arc, +} + +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, +} + +pub struct StrataManager { + pub db: Arc, + pub limits: Limits, + pub focused_app: Arc>, + pub submit_tx: tokio::sync::mpsc::UnboundedSender, + /// 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) -> 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 { + 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 { + 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> { + 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 = 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 = 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<()>; +} diff --git a/strata-daemon/src/main.rs b/strata-daemon/src/main.rs new file mode 100644 index 0000000..b8c7507 --- /dev/null +++ b/strata-daemon/src/main.rs @@ -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::(); + // GJS submit path: GJS reads via Meta.Selection, sends bytes over D-Bus. + let (submit_tx, mut submit_rx) = mpsc::unbounded_channel::(); + // 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, + 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, + db: &Arc, + 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 = 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, 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, 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)) +} diff --git a/strata@edu4rdshl.dev/README.md b/strata@edu4rdshl.dev/README.md new file mode 100644 index 0000000..0636903 --- /dev/null +++ b/strata@edu4rdshl.dev/README.md @@ -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 45–50 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 45–50) +├── 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** (50–2000 items) +- **Items per page** (20–200, rows fetched on open and on each scroll-to-bottom) +- **Maximum text size** (1–100 MB, larger items are not stored) +- **Maximum image size** (1–100 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. diff --git a/strata@edu4rdshl.dev/dbus.js b/strata@edu4rdshl.dev/dbus.js new file mode 100644 index 0000000..d76e7ba --- /dev/null +++ b/strata@edu4rdshl.dev/dbus.js @@ -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 = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const StrataProxy = Gio.DBusProxy.makeProxyWrapper(STRATA_IFACE_XML); + +export const BUS_NAME = 'org.gnome.Strata'; +export const OBJECT_PATH = '/org/gnome/Strata'; diff --git a/strata@edu4rdshl.dev/extension.js b/strata@edu4rdshl.dev/extension.js new file mode 100644 index 0000000..385561d --- /dev/null +++ b/strata@edu4rdshl.dev/extension.js @@ -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'); + } +} diff --git a/strata@edu4rdshl.dev/metadata.json b/strata@edu4rdshl.dev/metadata.json new file mode 100644 index 0000000..497deb3 --- /dev/null +++ b/strata@edu4rdshl.dev/metadata.json @@ -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" +} diff --git a/strata@edu4rdshl.dev/prefs.js b/strata@edu4rdshl.dev/prefs.js new file mode 100644 index 0000000..12fe690 --- /dev/null +++ b/strata@edu4rdshl.dev/prefs.js @@ -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(); + } +} diff --git a/strata@edu4rdshl.dev/schemas/org.gnome.shell.extensions.strata.gschema.xml b/strata@edu4rdshl.dev/schemas/org.gnome.shell.extensions.strata.gschema.xml new file mode 100644 index 0000000..ad37c85 --- /dev/null +++ b/strata@edu4rdshl.dev/schemas/org.gnome.shell.extensions.strata.gschema.xml @@ -0,0 +1,76 @@ + + + + + + 200 + + Maximum number of clipboard history items to keep + All stored items are searchable (text only) and reachable via scroll. Higher values use more disk space (~30KB per image, ~1KB per text item). + + + + 1 + + Maximum text clipboard item size (MB) + Text payloads larger than this are not stored. + + + + 5 + + Maximum image clipboard item size (MB) + Image payloads larger than this are not stored. + + + + 50 + + Number of items to fetch per history page + The panel loads this many rows on open and again each time the scroll reaches the bottom. + + + + v']]]> + Keyboard shortcut to open the clipboard panel + + + + 480 + Width of the clipboard panel in pixels + + + + 600 + Maximum height of the clipboard panel in pixels + + + + true + Move the selected item to the top of the list when activated + When enabled, clicking or pressing Enter on a history item moves it to position 1 in the list. + + + + + + + + + + + + + 'top-center' + Position of the clipboard panel on screen + + + + + App WM class substrings to exclude from clipboard history + 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. + + + + diff --git a/strata@edu4rdshl.dev/stylesheet.css b/strata@edu4rdshl.dev/stylesheet.css new file mode 100644 index 0000000..0808909 --- /dev/null +++ b/strata@edu4rdshl.dev/stylesheet.css @@ -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); +} diff --git a/strata@edu4rdshl.dev/ui/clipboardItem.js b/strata@edu4rdshl.dev/ui/clipboardItem.js new file mode 100644 index 0000000..01a2b0f --- /dev/null +++ b/strata@edu4rdshl.dev/ui/clipboardItem.js @@ -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) - 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; + } +}); diff --git a/strata@edu4rdshl.dev/ui/panel.js b/strata@edu4rdshl.dev/ui/panel.js new file mode 100644 index 0000000..82d6818 --- /dev/null +++ b/strata@edu4rdshl.dev/ui/panel.js @@ -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} id → widget */ + this._widgets = new Map(); + /** @type {Map} 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); + } +}