mirror of
https://github.com/edu4rdshl/Strata.git
synced 2026-07-17 23:24:46 +00:00
Initial commit
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
commit
350d647953
27 changed files with 6694 additions and 0 deletions
63
.github/workflows/ci.yml
vendored
Normal file
63
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
daemon:
|
||||
name: Rust daemon
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
strata-daemon/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('strata-daemon/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --manifest-path strata-daemon/Cargo.toml -- --check
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --manifest-path strata-daemon/Cargo.toml
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --manifest-path strata-daemon/Cargo.toml -- -W clippy::all
|
||||
|
||||
- name: Test
|
||||
run: cargo test --manifest-path strata-daemon/Cargo.toml
|
||||
|
||||
extension:
|
||||
name: GNOME extension
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install glib-compile-schemas
|
||||
run: sudo apt-get update -q && sudo apt-get install -y libglib2.0-bin
|
||||
|
||||
- name: Compile GSettings schema
|
||||
run: glib-compile-schemas strata@edu4rdshl.dev/schemas/
|
||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Rust build artifacts
|
||||
strata-daemon/target/
|
||||
|
||||
# Bundled binary (no longer shipped - daemon must be installed separately)
|
||||
strata@edu4rdshl.dev/bin/
|
||||
|
||||
# Screenshots / scratch images
|
||||
*.png
|
||||
|
||||
# External reference code (not part of this project)
|
||||
external_sources/
|
||||
|
||||
# Editor / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
**/gschemas.compiled
|
||||
99
AGENTS.md
Normal file
99
AGENTS.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# AGENTS.md
|
||||
|
||||
Guidelines for AI agents working on this codebase.
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
strata-daemon/ Rust daemon (tokio, zbus, rusqlite)
|
||||
strata@edu4rdshl.dev/ GNOME Shell extension (GJS) - pure JS, no binary
|
||||
schemas/ GSettings schema XML
|
||||
ui/ Panel and item widgets
|
||||
contrib/systemd/ systemd user service unit for distro packaging
|
||||
ARCHITECTURE.md In-depth design document
|
||||
```
|
||||
|
||||
## Core principle
|
||||
|
||||
"JS draws, Rust thinks." The extension must never block the GNOME Shell
|
||||
compositor. All heavy work (hashing, storage, search, thumbnails) lives in
|
||||
the daemon. The extension only renders and forwards events.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Build daemon + compile schemas
|
||||
make
|
||||
|
||||
# Install daemon binary to ~/.local/bin (must be in PATH)
|
||||
make install-daemon
|
||||
|
||||
# Install extension to ~/.local/share/gnome-shell/extensions/
|
||||
make install
|
||||
```
|
||||
|
||||
## Lint
|
||||
|
||||
```bash
|
||||
cargo clippy --manifest-path=strata-daemon/Cargo.toml
|
||||
```
|
||||
|
||||
No linter is configured for GJS. Follow the existing code style.
|
||||
|
||||
## Deploy to a test VM
|
||||
|
||||
```bash
|
||||
# Build and copy daemon binary to VM
|
||||
cargo build --release --manifest-path=strata-daemon/Cargo.toml
|
||||
scp strata-daemon/target/release/strata-daemon fedoradev:~/.local/bin/
|
||||
|
||||
# Sync extension (JS only)
|
||||
rsync -a strata@edu4rdshl.dev/ fedoradev:~/.local/share/gnome-shell/extensions/strata@edu4rdshl.dev/ --exclude='.git'
|
||||
ssh fedoradev 'glib-compile-schemas ~/.local/share/gnome-shell/extensions/strata@edu4rdshl.dev/schemas/'
|
||||
# Then log out and back in (Wayland) or Alt+F2 r (X11)
|
||||
```
|
||||
|
||||
## Key files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `strata-daemon/src/main.rs` | Entry point, Wayland monitor, GJS submit task, `process_change`/`process_bytes` |
|
||||
| `strata-daemon/src/dbus_service.rs` | D-Bus interface, `Limits` struct, all D-Bus methods |
|
||||
| `strata-daemon/src/db.rs` | SQLite schema, FTS5, insert, prune, search, thumbnail |
|
||||
| `strata-daemon/src/config.rs` | Data dir, DB path (`~/.local/share/strata/clipboard.db`) |
|
||||
| `strata@edu4rdshl.dev/extension.js` | Daemon lifecycle (PATH lookup + systemd detection), clipboard ingest, focus tracking, `_pushConfig` |
|
||||
| `strata@edu4rdshl.dev/ui/panel.js` | Popup panel, lazy-load pagination, search, `_pageSize` |
|
||||
| `strata@edu4rdshl.dev/ui/clipboardItem.js` | Individual row widget |
|
||||
| `strata@edu4rdshl.dev/prefs.js` | Preferences window (Adw) |
|
||||
| `strata@edu4rdshl.dev/dbus.js` | D-Bus proxy definition and XML interface |
|
||||
| `contrib/systemd/strata-daemon.service` | systemd user unit for distro packaging |
|
||||
|
||||
## D-Bus interface summary
|
||||
|
||||
Service `org.gnome.Strata`, object `/org/gnome/Strata`, interface `org.gnome.Strata.Manager`.
|
||||
|
||||
Methods: `GetHistory(offset u32, limit u32) -> json s`,
|
||||
`SearchHistory(query s, limit u32) -> json s`,
|
||||
`GetThumbnail(id s) -> png_bytes ay`,
|
||||
`GetItemContent(id s) -> (mime_type s, content_b64 s)`,
|
||||
`SetClipboard(id s)`, `DeleteItem(id s)`, `ClearHistory()`,
|
||||
`SetConfig(max_history u32, max_text_bytes u32, max_image_bytes u32)`,
|
||||
`SubmitItem(mime s, data ay)`, `SetFocusedApp(app_id s)`, `Shutdown()`.
|
||||
|
||||
Signals: `ItemAdded(id s, mime_type s, preview s)`, `ItemDeleted(id s)`, `HistoryCleared()`.
|
||||
|
||||
## GSettings keys
|
||||
|
||||
`max-history`, `page-size`, `max-text-mb`, `max-image-mb`, `panel-position`,
|
||||
`panel-width`, `panel-max-height`, `keyboard-shortcut`, `move-activated-to-top`,
|
||||
`excluded-apps`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- No `--` em-dashes, no emojis in code comments or docs.
|
||||
- Rust: use `tracing::info!`, `tracing::warn!`, `tracing::error!` (qualified path, not bare imports). No `println!` in daemon code.
|
||||
- GJS: `console.log('[Strata] ...')` / `console.error('[Strata] ...')` prefix for all extension logs.
|
||||
- Size limits travel as bytes over D-Bus. MB conversion happens extension-side.
|
||||
- `Ordering::Relaxed` is intentional on `Arc<AtomicUsize>` limits (advisory, not critical path).
|
||||
- Never execute clipboard content. Writes go through `wl-clipboard-rs` (`copy_multi`) in the daemon, never via shell subprocess or `eval`.
|
||||
- Excluded apps list is checked before storing any clipboard item.
|
||||
304
ARCHITECTURE.md
Executable file
304
ARCHITECTURE.md
Executable file
|
|
@ -0,0 +1,304 @@
|
|||
# Architecture
|
||||
|
||||
In-depth companion to [`README.md`](README.md). Covers how Strata is put
|
||||
together and the trade-offs at each layer.
|
||||
|
||||
## Design goals
|
||||
|
||||
1. Never block the GNOME Shell main loop.
|
||||
2. Never lose history. SQLite WAL, atomic upserts, daemon supervisor.
|
||||
3. Heavy work lives in Rust; the extension only renders UI and forwards
|
||||
events.
|
||||
4. Mime allowlist, size caps, password-manager opt-out, no shell exec,
|
||||
no markup parsing of clipboard content.
|
||||
|
||||
## Topology
|
||||
|
||||
```
|
||||
+---------------------------- GNOME Shell process (GJS) ---------------------------+
|
||||
| |
|
||||
| Meta.Selection --> extension.js --> dbus.js (Gio.DBusProxy) ---+ |
|
||||
| | |
|
||||
| panel.js <----------- ui/clipboardItem.js <--------------------+ |
|
||||
| ^ |
|
||||
| | ItemAdded / ItemDeleted / HistoryCleared |
|
||||
+--------+-------------------------------------------------------------------------+
|
||||
|
|
||||
| session D-Bus (org.gnome.Strata)
|
||||
v
|
||||
+----------------------- strata-daemon (Rust process) --------------------------+
|
||||
| |
|
||||
| zbus interface --> tokio executor --> spawn_blocking --> rusqlite (Mutex) |
|
||||
| | | |
|
||||
| | v |
|
||||
| | +---------------------+ |
|
||||
| | | SQLite (WAL, FTS5) | |
|
||||
| | +---------------------+ |
|
||||
| v |
|
||||
| image::load_from_memory --> PNG thumbnail (~256 px) |
|
||||
| |
|
||||
+-------------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
## Process model
|
||||
|
||||
### Separate daemon
|
||||
|
||||
GJS is single-threaded and shares its main loop with the entire GNOME Shell
|
||||
compositor. Any synchronous syscall, hash computation, or SQLite query in
|
||||
JS can freeze the desktop. Strata moves all of it across a process
|
||||
boundary. The cost is one IPC hop per operation; D-Bus is shared-memory
|
||||
fast on the same machine, and the extension never `await`s anything in the
|
||||
hot ingest path (it fires `SubmitItem` and returns immediately).
|
||||
|
||||
### Daemon supervisor
|
||||
|
||||
`extension.js` owns the daemon. On enable it `Gio.Subprocess.spawnv`s
|
||||
`bin/strata-daemon` and registers a watchdog:
|
||||
|
||||
- If the child exits, schedule a respawn with exponential backoff
|
||||
(1 s, 2 s, 4 s, capped at 30 s).
|
||||
- After 5 rapid restarts within 60 s, stop and surface a notification.
|
||||
- On disable, send `Shutdown` over D-Bus first, then `SIGTERM` if it
|
||||
doesn't exit within 2 s.
|
||||
|
||||
### Startup
|
||||
|
||||
The extension listens for `notify::g-name-owner` on the D-Bus proxy. When
|
||||
the daemon's `org.gnome.Strata` name becomes owned, the panel triggers
|
||||
its initial fetch. No polling, no fixed delays.
|
||||
|
||||
## Ingest path
|
||||
|
||||
```
|
||||
Meta.Selection.OwnerChanged
|
||||
|
|
||||
+-- _readClipboard()
|
||||
|
|
||||
+-- _pickMime() enforces strict allowlist
|
||||
+-- skip if x-kde-passwordManagerHint present
|
||||
+-- Meta.Selection.transfer_async() --> Uint8Array
|
||||
+-- SubmitItemAsync(mime, rawBytes) (D-Bus 'ay')
|
||||
|
|
||||
v
|
||||
daemon::dbus_service::submit_item(mime, Vec<u8>)
|
||||
|
|
||||
+-- spawn_blocking(move || {
|
||||
hash = blake3(bytes)
|
||||
upsert by content_hash (returns existing id or new)
|
||||
on new image: decode + thumbnail
|
||||
emit ItemAdded
|
||||
prune to max_history
|
||||
+-- emit ItemDeleted per pruned id
|
||||
})
|
||||
```
|
||||
|
||||
### Content hash
|
||||
|
||||
`blake3` for dedup. A unique index on `content_hash` makes the upsert
|
||||
atomic: copying the same content twice updates `created_at` instead of
|
||||
creating a duplicate row.
|
||||
|
||||
### Wire format
|
||||
|
||||
`SubmitItem` takes `ay` (D-Bus byte array) directly. No base64 encoding
|
||||
in JS, no decode step in Rust.
|
||||
|
||||
### Mime allowlist
|
||||
|
||||
`Meta.Selection.transfer_async` reads the full clipboard payload into GJS
|
||||
memory before any size check is possible, so the only safe defence
|
||||
against a hostile or buggy app putting a 1 GB blob on the clipboard is to
|
||||
refuse mime types we don't recognise. The list lives in
|
||||
`extension.js::_pickMime` and `daemon::main::pick_mime` and covers the
|
||||
common text and image types. Password-manager hint mimes
|
||||
(`x-kde-passwordManagerHint`) are skipped on both paths.
|
||||
|
||||
## Storage
|
||||
|
||||
### Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE clipboard_history (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
mime_type TEXT NOT NULL,
|
||||
content_text TEXT, -- one of these two is populated
|
||||
content_blob BLOB, -- (text vs binary)
|
||||
thumbnail_blob BLOB, -- pre-decoded PNG, ~256 px
|
||||
content_hash TEXT NOT NULL, -- blake3 of raw bytes
|
||||
source_app TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_created_at ON clipboard_history (created_at DESC);
|
||||
CREATE UNIQUE INDEX idx_hash ON clipboard_history (content_hash);
|
||||
|
||||
CREATE VIRTUAL TABLE clipboard_fts USING fts5(
|
||||
content_text,
|
||||
content='clipboard_history',
|
||||
content_rowid='rowid',
|
||||
tokenize='unicode61 remove_diacritics 2'
|
||||
);
|
||||
-- plus AI / AD / AU triggers keeping FTS in sync with the base table.
|
||||
```
|
||||
|
||||
### PRAGMAs
|
||||
|
||||
```
|
||||
journal_mode = WAL -- readers don't block the writer
|
||||
synchronous = NORMAL -- fsync on commit, not on every write
|
||||
foreign_keys = ON
|
||||
```
|
||||
|
||||
### FTS5 search
|
||||
|
||||
Full-text index over `content_text` only. Images and other binaries are
|
||||
not indexed; an empty search shows everything, a non-empty search shows
|
||||
only matching text items.
|
||||
|
||||
`tokenize='unicode61 remove_diacritics 2'` gives O(log n) prefix search
|
||||
and matches across diacritics (searching `cafe` finds `café`).
|
||||
|
||||
The FTS5 table uses `content='clipboard_history'` (external content), so
|
||||
text is stored once in the base table and FTS5 holds only the inverted
|
||||
index.
|
||||
|
||||
### FTS5 query construction
|
||||
|
||||
User input is split into whitespace tokens. Each token has embedded `"`
|
||||
doubled (FTS5 escape) and is wrapped in `"..."` with a `*` prefix
|
||||
suffix:
|
||||
|
||||
```
|
||||
input: cafe hello
|
||||
tokens: ["cafe", "hello"]
|
||||
FTS5: "cafe"* "hello"*
|
||||
```
|
||||
|
||||
The constructed string is bound as a parameter value (`?N`), not
|
||||
interpolated into SQL. A malformed FTS5 escape can only affect the
|
||||
search query, not the SQL parser.
|
||||
|
||||
### Pruning
|
||||
|
||||
`upsert_item` checks `COUNT(*) > max_history` after each insert and
|
||||
deletes the oldest excess rows by `created_at ASC`, returning their ids.
|
||||
The D-Bus layer emits `ItemDeleted` for each so the extension can unlink
|
||||
the matching `~/.cache/strata/thumbnails/<id>.png`.
|
||||
|
||||
## Concurrency
|
||||
|
||||
The Rust daemon runs `tokio::main(flavor = "multi_thread")`. zbus
|
||||
dispatches each incoming method call on the executor. rusqlite is sync,
|
||||
so every DB call is wrapped:
|
||||
|
||||
```rust
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.lock(); // poison-recovering wrapper
|
||||
db::upsert_item(&guard, ...)
|
||||
}).await?
|
||||
```
|
||||
|
||||
The D-Bus reactor stays responsive while disk I/O runs on the blocking
|
||||
pool. One mutex serialises writers (SQLite is single-writer in WAL). The
|
||||
lock wrapper takes the inner value even if a previous holder panicked,
|
||||
so a single bad task can't poison the global state.
|
||||
|
||||
## Memory bounds
|
||||
|
||||
- Mime allowlist gates which payloads are even read.
|
||||
- Text and image size caps are user-configurable (defaults 1 MB and 5 MB).
|
||||
Payloads larger than the configured cap are rejected at `submit_item`
|
||||
before storage. The extension reads the caps from GSettings and pushes
|
||||
them to the daemon via `SetConfig`, so changes apply at runtime.
|
||||
- Thumbnails are decoded once at ingest, stored as PNG. The UI fetches
|
||||
them lazily via `GetThumbnail(id) -> ay` only for visible rows.
|
||||
- History pagination uses the configurable `page-size` setting (default
|
||||
50). The panel loads one page on open and one more each time the
|
||||
scroll reaches the bottom. The full table never sits in JS memory.
|
||||
|
||||
## Lazy loading
|
||||
|
||||
Two independent lazy layers keep the panel responsive regardless of
|
||||
history size.
|
||||
|
||||
### Paginated history
|
||||
|
||||
`GetHistory(offset, limit)` returns metadata only (id, mime, short text
|
||||
preview, timestamp). The panel loads `page-size` rows on open, then
|
||||
another page each time the scroll position passes ~80 % of the viewport.
|
||||
The Rust side serves these from the `idx_created_at DESC` index with
|
||||
`LIMIT/OFFSET`, which stays O(log n) for any history size.
|
||||
|
||||
Search shortcuts this path: when the search box is non-empty the panel
|
||||
calls `SearchHistory(query, limit)` instead and disables scroll-driven
|
||||
appends, so an in-progress search and a scroll event cannot race.
|
||||
|
||||
### On-demand thumbnails
|
||||
|
||||
`GetHistory` does not return image bytes. For each image row,
|
||||
`ui/clipboardItem.js` builds the row with a placeholder icon and then:
|
||||
|
||||
1. Checks `~/.cache/strata/thumbnails/<id>.png`. If present, loads from
|
||||
disk via `St.Icon` with a `file://` URI.
|
||||
2. Otherwise calls `GetThumbnail(id)`, which returns the
|
||||
pre-decoded PNG bytes the daemon stored in `thumbnail_blob` at
|
||||
ingest time. The bytes are written to the cache file, then loaded.
|
||||
3. On `ItemDeleted` (including prune-driven deletes), the cache file is
|
||||
unlinked.
|
||||
|
||||
Effect: scrolling past 1000 image rows costs zero D-Bus traffic for the
|
||||
rows above and below the viewport. Each thumbnail is fetched at most
|
||||
once per process lifetime; reopens after the first fetch read the PNG
|
||||
straight from the page cache.
|
||||
|
||||
The daemon does the expensive part (image decode + resize) exactly once,
|
||||
at ingest, on the blocking pool. The UI never decodes a full-resolution
|
||||
image.
|
||||
|
||||
## UI invariants
|
||||
|
||||
- `St.Label({ text: ... })` only. No `set_markup`, so clipboard content
|
||||
can never inject Pango markup.
|
||||
- List updates are batched via `GLib.idle_add` in chunks of 20.
|
||||
- Search has a 150 ms debounce and an epoch counter; stale responses
|
||||
that arrive after a newer query are dropped.
|
||||
- Paste-back uses `St.Clipboard.set_text` for text or
|
||||
`Meta.SelectionSourceMemory.new` + `set_owner` for binary. No code
|
||||
path in Strata executes clipboard content (no `spawn`, no
|
||||
`launch_uri`, no `show_uri`).
|
||||
|
||||
## Wayland clipboard monitor
|
||||
|
||||
The daemon contains a `wl-clipboard-rs` monitor for `ext-data-control-v1`
|
||||
and `zwlr-data-control-v1`. GNOME's Mutter does not expose either, so on
|
||||
GNOME the monitor logs INFO and all ingest comes from GJS via
|
||||
`Meta.Selection` + `SubmitItem`.
|
||||
|
||||
The monitor is kept because it makes the daemon usable standalone on
|
||||
wlroots-based compositors (Sway, Hyprland) with a non-GNOME front-end,
|
||||
and it is small and isolated.
|
||||
|
||||
## Security boundary
|
||||
|
||||
| Boundary | Threat | Mitigation |
|
||||
|---|---|---|
|
||||
| App to clipboard | Huge blob OOMs Shell | Mime allowlist, size caps |
|
||||
| App to history | Password leak | `x-kde-passwordManagerHint` opt-out |
|
||||
| User to search | SQL injection | rusqlite `params![]`, FTS5 input bound as value |
|
||||
| Daemon to FS | Path traversal via id | Ids are server-generated UUID v4 |
|
||||
| Daemon to extension | Signal spoofing | D-Bus enforces single owner of `org.gnome.Strata` |
|
||||
| Stored item to paste | Command execution | No spawn, no launch_uri, no markup parsing |
|
||||
|
||||
## Non-GNOME front-end
|
||||
|
||||
The daemon's D-Bus interface is the contract:
|
||||
|
||||
1. Spawn `strata-daemon`.
|
||||
2. Subscribe to `ItemAdded`, `ItemDeleted`, `HistoryCleared`.
|
||||
3. Call `GetHistory`, `SearchHistory`, `SetClipboard`, etc.
|
||||
4. On wlroots compositors either let the built-in `wl-clipboard-rs`
|
||||
monitor handle ingest, or read the clipboard yourself and call
|
||||
`SubmitItem(mime, bytes)`.
|
||||
|
||||
See `busctl` examples in [`strata-daemon/README.md`](strata-daemon/README.md).
|
||||
40
CHANGELOG.md
Normal file
40
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] - 2026-05-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MIME priority: plain text now preferred over rich text.**
|
||||
When an application (code editors, web browsers, etc.) advertises both a
|
||||
plain-text and a rich-text (`text/html`, `text/rtf`) variant on the clipboard,
|
||||
Strata now captures the plain-text variant. Previously, `text/html` ranked
|
||||
above `text/plain`, causing syntax-highlighted HTML markup to be stored
|
||||
instead of the actual source text when copying from code editors.
|
||||
Rich-text types are still captured when no plain-text alternative is offered.
|
||||
Fix applied to both the Rust daemon (`pick_mime`) and the GNOME Shell
|
||||
extension (`_pickMime`) to keep them in sync.
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] - 2026-05-24
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
- Rust daemon (`strata-daemon`) with SQLite/FTS5 storage, Wayland clipboard
|
||||
monitor, D-Bus interface (`org.gnome.Strata`), and image thumbnail support.
|
||||
- GNOME Shell extension (`strata@edu4rdshl.dev`) with popup panel, lazy-loaded
|
||||
pagination, full-text search, image thumbnails, and paste-back.
|
||||
- Preferences window (Adwaita) for history size, size limits, keyboard
|
||||
shortcut, panel position/width, and excluded-app list.
|
||||
- Daemon lifecycle management: extension detects an existing systemd-managed
|
||||
daemon and falls back to spawning from `PATH`; shows a desktop notification
|
||||
when the binary is not found.
|
||||
- `contrib/systemd/strata-daemon.service` for distribution packaging.
|
||||
- `Makefile` with `install-daemon`, `install`, and `pack` targets.
|
||||
46
Makefile
Normal file
46
Makefile
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
EXTENSION_UUID = strata@edu4rdshl.dev
|
||||
DAEMON_RELEASE = strata-daemon/target/release/strata-daemon
|
||||
SCHEMA_DIR = $(EXTENSION_UUID)/schemas
|
||||
INSTALL_DIR = $(HOME)/.local/share/gnome-shell/extensions/$(EXTENSION_UUID)
|
||||
DAEMON_INSTALL = $(HOME)/.local/bin
|
||||
|
||||
.PHONY: all daemon schemas install install-daemon pack clean
|
||||
|
||||
all: daemon schemas
|
||||
|
||||
daemon:
|
||||
cargo build --release --manifest-path=strata-daemon/Cargo.toml
|
||||
|
||||
schemas:
|
||||
glib-compile-schemas $(SCHEMA_DIR)
|
||||
|
||||
# Install the daemon binary to ~/.local/bin (must be in PATH).
|
||||
install-daemon: daemon
|
||||
mkdir -p $(DAEMON_INSTALL)
|
||||
cp $(DAEMON_RELEASE) $(DAEMON_INSTALL)/strata-daemon
|
||||
@echo "Installed daemon to $(DAEMON_INSTALL)/strata-daemon"
|
||||
@echo "Make sure $(DAEMON_INSTALL) is in your PATH."
|
||||
|
||||
# Install the GNOME Shell extension only (daemon must already be in PATH).
|
||||
install: schemas
|
||||
mkdir -p $(INSTALL_DIR)/schemas $(INSTALL_DIR)/ui
|
||||
cp $(EXTENSION_UUID)/schemas/*.gschema.xml $(INSTALL_DIR)/schemas/
|
||||
cp $(SCHEMA_DIR)/gschemas.compiled $(INSTALL_DIR)/schemas/
|
||||
cp $(EXTENSION_UUID)/*.js $(INSTALL_DIR)/
|
||||
cp $(EXTENSION_UUID)/*.css $(INSTALL_DIR)/
|
||||
cp $(EXTENSION_UUID)/metadata.json $(INSTALL_DIR)/
|
||||
cp $(EXTENSION_UUID)/ui/*.js $(INSTALL_DIR)/ui/
|
||||
@echo "Installed to $(INSTALL_DIR)"
|
||||
@echo "Run: gnome-extensions enable $(EXTENSION_UUID)"
|
||||
|
||||
# Pack the extension (JS only - no binary).
|
||||
pack: schemas
|
||||
gnome-extensions pack $(EXTENSION_UUID) \
|
||||
--extra-source=ui \
|
||||
--force
|
||||
@echo "Packed: $(EXTENSION_UUID).shell-extension.zip"
|
||||
|
||||
clean:
|
||||
cargo clean --manifest-path=strata-daemon/Cargo.toml
|
||||
rm -f $(SCHEMA_DIR)/gschemas.compiled
|
||||
rm -f $(EXTENSION_UUID).shell-extension.zip
|
||||
111
README.md
Normal file
111
README.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# Strata
|
||||
|
||||
A fast, stutter-free clipboard manager for GNOME Shell.
|
||||
|
||||
All heavy work (hashing, decoding, storage, search, thumbnails) lives in a Rust
|
||||
daemon. The GNOME Shell extension only renders UI and forwards events over
|
||||
D-Bus, so the compositor is never blocked, even with thousands of items.
|
||||
|
||||
## Architecture
|
||||
|
||||
Strata is **two components**, and you need **both** for it to work:
|
||||
|
||||
| Component | Language | Role |
|
||||
|---|---|---|
|
||||
| [`strata-daemon/`](strata-daemon/) | Rust + tokio + zbus | Storage (SQLite + FTS5), dedup, thumbnails, D-Bus service `org.gnome.Strata` |
|
||||
| [`strata@edu4rdshl.dev/`](strata@edu4rdshl.dev/) | GJS (GNOME Shell extension) | Top-bar panel, search UI, paste-back, clipboard ingest |
|
||||
|
||||
The extension auto-connects to the daemon on enable. If the daemon is managed
|
||||
by systemd (or another init system), the extension detects it and skips
|
||||
spawning its own copy. If no daemon is running, the extension looks for
|
||||
`strata-daemon` in `$PATH` and spawns it directly.
|
||||
|
||||
```
|
||||
GNOME Shell (GJS) ──D-Bus──▶ strata-daemon ──▶ SQLite (~/.local/share/strata)
|
||||
│
|
||||
└──▶ thumbnails (~/.cache/strata)
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- GNOME Shell 50 (tested). May work on 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.
|
||||
14
contrib/systemd/strata-daemon.service
Normal file
14
contrib/systemd/strata-daemon.service
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[Unit]
|
||||
Description=Strata clipboard manager daemon
|
||||
Documentation=https://github.com/Edu4rdSHL/Strata
|
||||
# Start after the D-Bus session bus and the graphical session are available.
|
||||
After=graphical-session.target
|
||||
PartOf=graphical-session.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/strata-daemon
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
2170
strata-daemon/Cargo.lock
generated
Normal file
2170
strata-daemon/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
54
strata-daemon/Cargo.toml
Normal file
54
strata-daemon/Cargo.toml
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
[package]
|
||||
name = "strata-daemon"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
description = "Strata clipboard manager backend daemon"
|
||||
|
||||
[[bin]]
|
||||
name = "strata-daemon"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# D-Bus (async, tokio-native)
|
||||
zbus = { version = "4", features = ["tokio"] }
|
||||
|
||||
# Wayland clipboard monitoring (custom event loop)
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
|
||||
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
||||
|
||||
# Clipboard read / write (paste::get_contents + copy::copy_multi)
|
||||
# Supports ext-data-control-v1 (GNOME 47+) and zwlr-data-control-v1 fallback
|
||||
wl-clipboard-rs = "0.9"
|
||||
|
||||
# Storage
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
# Image thumbnail generation (runs in spawn_blocking — never blocks async runtime)
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
base64 = { version = "0.22", features = ["std"] }
|
||||
|
||||
# Fast content hashing for deduplication
|
||||
blake3 = "1"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
|
||||
# XDG paths
|
||||
dirs = "5"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
101
strata-daemon/README.md
Normal file
101
strata-daemon/README.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# strata-daemon
|
||||
|
||||
The Rust backend for [Strata](../README.md). Speaks D-Bus, stores history in
|
||||
SQLite, generates image thumbnails, and indexes text for full-text search.
|
||||
|
||||
It is **not GNOME-specific**. Any D-Bus client can use it.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
# binary at target/release/strata-daemon
|
||||
```
|
||||
|
||||
The GNOME extension spawns this binary automatically; you only need to run it
|
||||
manually if you're using a non-GNOME front-end.
|
||||
|
||||
## Storage
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `~/.local/share/strata/clipboard.db` | History DB (WAL mode, FTS5 index) |
|
||||
| `~/.cache/strata/thumbnails/<id>.png` | Per-item image thumbnails |
|
||||
|
||||
## D-Bus interface
|
||||
|
||||
- **Bus name:** `org.gnome.Strata`
|
||||
- **Object path:** `/org/gnome/Strata`
|
||||
- **Interface:** `org.gnome.Strata.Manager`
|
||||
|
||||
| Member | Signature | Notes |
|
||||
|---|---|---|
|
||||
| `SubmitItem` (method) | `(s ay) → ()` | Ingest a clipboard payload (mime, raw bytes) |
|
||||
| `GetHistory` (method) | `(u u) → s` | `(offset, limit)` → JSON array |
|
||||
| `SearchHistory` (method) | `(s u) → s` | FTS5 query → JSON array |
|
||||
| `GetThumbnail` (method) | `s → ay` | PNG bytes for item id |
|
||||
| `GetItemContent` (method) | `s → (ss)` | `(mime, base64)` for paste-back |
|
||||
| `SetClipboard` (method) | `s → ()` | Re-copy a stored item |
|
||||
| `DeleteItem` (method) | `s → ()` | |
|
||||
| `ClearHistory` (method) | `() → ()` | |
|
||||
| `Shutdown` (method) | `() → ()` | Graceful exit |
|
||||
| `SetConfig` (method) | `(uuu) → ()` | `(max_history, max_text_bytes, max_image_bytes)`; 0 means leave unchanged |
|
||||
| `ItemAdded` (signal) | `(sss)` | `(id, mime, preview)` |
|
||||
| `ItemDeleted` (signal) | `s` | |
|
||||
| `HistoryCleared` (signal) | `()` | |
|
||||
|
||||
### Example: drive it from the shell
|
||||
|
||||
```sh
|
||||
# Start the daemon (foreground)
|
||||
./target/release/strata-daemon
|
||||
|
||||
# In another terminal:
|
||||
busctl --user call org.gnome.Strata /org/gnome/Strata \
|
||||
org.gnome.Strata.Manager GetHistory uu 0 10
|
||||
|
||||
# Submit text:
|
||||
busctl --user call org.gnome.Strata /org/gnome/Strata \
|
||||
org.gnome.Strata.Manager SubmitItem say "text/plain;charset=utf-8" 5 104 101 108 108 111
|
||||
|
||||
# Listen for new items:
|
||||
busctl --user monitor org.gnome.Strata
|
||||
```
|
||||
|
||||
## Supported clipboard payloads
|
||||
|
||||
Strict mime allowlist. Per-type size caps default to 1 MB for text and
|
||||
5 MB for images; both are runtime-configurable via the `SetConfig`
|
||||
D-Bus method (the GNOME extension wires this to its GSettings).
|
||||
|
||||
- Text: `text/plain*`, `UTF8_STRING`, `STRING`, `TEXT`, `text/rtf`,
|
||||
`text/markdown`, `text/html`, `text/uri-list`, `x-special/nautilus-clipboard`,
|
||||
`application/x-kde-cutselection`, `application/rtf`
|
||||
- Images: `image/png`, `image/jpeg`, `image/jpg`, `image/gif`, `image/webp`,
|
||||
`image/bmp`, `image/tiff`, `image/avif`, `image/x-icon`, `image/svg+xml`
|
||||
|
||||
Items carrying `x-kde-passwordManagerHint` are silently dropped
|
||||
(KeePassXC / Bitwarden convention).
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs Entry point, clipboard ingest, mime allowlist
|
||||
├── config.rs Config loading + size caps
|
||||
├── db.rs SQLite schema, FTS5, parameterised queries
|
||||
├── dbus_service.rs zbus interface implementation
|
||||
└── clipboard/
|
||||
├── monitor.rs Optional wl-clipboard-rs monitor (unused on GNOME)
|
||||
└── writer.rs Paste-back helpers
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
cargo test --release
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0-or-later.
|
||||
2
strata-daemon/src/clipboard/mod.rs
Normal file
2
strata-daemon/src/clipboard/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod monitor;
|
||||
pub mod writer;
|
||||
315
strata-daemon/src/clipboard/monitor.rs
Normal file
315
strata-daemon/src/clipboard/monitor.rs
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
/// Wayland clipboard monitor using the ext-data-control-v1 protocol (GNOME 47+)
|
||||
/// with a zwlr-data-control-v1 fallback for wlroots-based compositors.
|
||||
///
|
||||
/// Runs on a dedicated OS thread (NOT on the tokio runtime) to avoid blocking
|
||||
/// the async executor. Communicates clipboard change events to the tokio world
|
||||
/// via an `mpsc::UnboundedSender`.
|
||||
///
|
||||
/// Protocol reference: staging/ext-data-control/ext-data-control-v1.xml
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use wayland_client::{
|
||||
backend::ObjectId,
|
||||
globals::{registry_queue_init, GlobalListContents},
|
||||
protocol::{wl_registry, wl_seat},
|
||||
Connection, Dispatch, Proxy, QueueHandle,
|
||||
};
|
||||
use wayland_protocols::ext::data_control::v1::client::{
|
||||
ext_data_control_device_v1::{self, ExtDataControlDeviceV1},
|
||||
ext_data_control_manager_v1::{self, ExtDataControlManagerV1},
|
||||
ext_data_control_offer_v1::{self, ExtDataControlOfferV1},
|
||||
};
|
||||
use wayland_protocols_wlr::data_control::v1::client::{
|
||||
zwlr_data_control_device_v1::{self, ZwlrDataControlDeviceV1},
|
||||
zwlr_data_control_manager_v1::{self, ZwlrDataControlManagerV1},
|
||||
zwlr_data_control_offer_v1::{self, ZwlrDataControlOfferV1},
|
||||
};
|
||||
|
||||
/// A clipboard change notification: the compositor has set a new selection.
|
||||
/// `mime_types` lists all MIME types the new selection provides.
|
||||
#[derive(Debug)]
|
||||
pub struct ClipboardChange {
|
||||
pub mime_types: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct MonitorState {
|
||||
/// Pending offers keyed by Wayland object ID.
|
||||
/// Each entry holds the offered MIME types as they arrive.
|
||||
offers: HashMap<ObjectId, Vec<String>>,
|
||||
tx: UnboundedSender<ClipboardChange>,
|
||||
}
|
||||
|
||||
impl MonitorState {
|
||||
fn new(tx: UnboundedSender<ClipboardChange>) -> Self {
|
||||
Self {
|
||||
offers: HashMap::new(),
|
||||
tx,
|
||||
}
|
||||
}
|
||||
|
||||
fn record_offer_mime(&mut self, id: ObjectId, mime: String) {
|
||||
self.offers.entry(id).or_default().push(mime);
|
||||
}
|
||||
|
||||
fn commit_selection(&mut self, offer_id: ObjectId) {
|
||||
// Take the mime list for this offer (and discard stale in-flight offers).
|
||||
let mimes = self.offers.remove(&offer_id).unwrap_or_default();
|
||||
self.offers.clear(); // discard any superseded offers
|
||||
if !mimes.is_empty() {
|
||||
let _ = self.tx.send(ClipboardChange { mime_types: mimes });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WlRegistry - required by registry_queue_init
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for MonitorState {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &wl_registry::WlRegistry,
|
||||
_event: wl_registry::Event,
|
||||
_data: &GlobalListContents,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
) {
|
||||
// Handled by GlobalList inside registry_queue_init.
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ext-data-control-v1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl Dispatch<ExtDataControlManagerV1, ()> for MonitorState {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &ExtDataControlManagerV1,
|
||||
_: ext_data_control_manager_v1::Event, // manager has no events - unreachable
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ExtDataControlDeviceV1, ()> for MonitorState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_device: &ExtDataControlDeviceV1,
|
||||
event: ext_data_control_device_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
ext_data_control_device_v1::Event::DataOffer { id } => {
|
||||
// Register the new offer object so subsequent Offer events can
|
||||
// find it.
|
||||
state.offers.insert(id.id(), Vec::new());
|
||||
}
|
||||
ext_data_control_device_v1::Event::Selection { id: Some(offer) } => {
|
||||
let oid = offer.id();
|
||||
state.commit_selection(oid);
|
||||
// Politely destroy the offer - the compositor will clean up
|
||||
// the Wayland object.
|
||||
offer.destroy();
|
||||
}
|
||||
ext_data_control_device_v1::Event::Selection { id: None } => {
|
||||
// null id means "clipboard cleared" - ignore.
|
||||
}
|
||||
ext_data_control_device_v1::Event::Finished => {
|
||||
tracing::warn!("ext-data-control device finished (compositor revoked access)");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ExtDataControlOfferV1, ()> for MonitorState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
proxy: &ExtDataControlOfferV1,
|
||||
event: ext_data_control_offer_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let ext_data_control_offer_v1::Event::Offer { mime_type } = event {
|
||||
state.record_offer_mime(proxy.id(), mime_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// zwlr-data-control-v1 (fallback for wlroots compositors)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl Dispatch<ZwlrDataControlManagerV1, ()> for MonitorState {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &ZwlrDataControlManagerV1,
|
||||
_: zwlr_data_control_manager_v1::Event, // manager has no events - unreachable
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZwlrDataControlDeviceV1, ()> for MonitorState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_device: &ZwlrDataControlDeviceV1,
|
||||
event: zwlr_data_control_device_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
zwlr_data_control_device_v1::Event::DataOffer { id } => {
|
||||
state.offers.insert(id.id(), Vec::new());
|
||||
}
|
||||
zwlr_data_control_device_v1::Event::Selection { id: Some(offer) } => {
|
||||
let oid = offer.id();
|
||||
state.commit_selection(oid);
|
||||
offer.destroy();
|
||||
}
|
||||
zwlr_data_control_device_v1::Event::Selection { id: None } => {}
|
||||
|
||||
zwlr_data_control_device_v1::Event::Finished => {
|
||||
tracing::warn!("zwlr-data-control device finished");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZwlrDataControlOfferV1, ()> for MonitorState {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
proxy: &ZwlrDataControlOfferV1,
|
||||
event: zwlr_data_control_offer_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let zwlr_data_control_offer_v1::Event::Offer { mime_type } = event {
|
||||
state.record_offer_mime(proxy.id(), mime_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wl_seat - needed for binding, no events we care about.
|
||||
wayland_client::delegate_noop!(MonitorState: ignore wl_seat::WlSeat);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Spawn the clipboard monitor on a dedicated OS thread.
|
||||
/// Returns immediately; the thread sends `ClipboardChange` events via `tx`.
|
||||
pub fn spawn(tx: UnboundedSender<ClipboardChange>) -> Result<()> {
|
||||
let conn = Connection::connect_to_env()
|
||||
.map_err(|e| anyhow::anyhow!("Could not connect to Wayland display: {e}"))?;
|
||||
|
||||
// Probe available protocols with a throwaway state.
|
||||
let protocol = probe_protocol(&conn)?;
|
||||
tracing::info!("Clipboard monitor using protocol: {:?}", protocol);
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("strata-wl-monitor".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = run_loop(conn, protocol, tx) {
|
||||
tracing::error!("Clipboard monitor exited with error: {e:#}");
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Protocol {
|
||||
Ext,
|
||||
Wlr,
|
||||
}
|
||||
|
||||
fn probe_protocol(conn: &Connection) -> Result<Protocol> {
|
||||
// A quick roundtrip to get the global list; no persistent state needed here.
|
||||
let (globals, _) = registry_queue_init::<MonitorState>(conn)?;
|
||||
|
||||
// ext-data-control-v1 preferred (GNOME 47+, KDE Plasma 6)
|
||||
if globals.contents().with_list(|list| {
|
||||
list.iter()
|
||||
.any(|g| g.interface == "ext_data_control_manager_v1")
|
||||
}) {
|
||||
return Ok(Protocol::Ext);
|
||||
}
|
||||
|
||||
// zwlr-data-control-v1 fallback (wlroots: Sway, Hyprland, etc.)
|
||||
if globals.contents().with_list(|list| {
|
||||
list.iter()
|
||||
.any(|g| g.interface == "zwlr_data_control_manager_v1")
|
||||
}) {
|
||||
return Ok(Protocol::Wlr);
|
||||
}
|
||||
|
||||
bail!(
|
||||
"No supported clipboard control protocol found.\n\
|
||||
- GNOME: requires GNOME 47+ (Mutter implements ext-data-control-v1)\n\
|
||||
- wlroots: requires zwlr-data-control-v1 (Sway, Hyprland, etc.)"
|
||||
);
|
||||
}
|
||||
|
||||
fn run_loop(
|
||||
conn: Connection,
|
||||
protocol: Protocol,
|
||||
tx: UnboundedSender<ClipboardChange>,
|
||||
) -> Result<()> {
|
||||
let (globals, mut queue) = registry_queue_init::<MonitorState>(&conn)?;
|
||||
let qh = queue.handle();
|
||||
let mut state = MonitorState::new(tx);
|
||||
|
||||
// First roundtrip to populate globals.
|
||||
queue.roundtrip(&mut state)?;
|
||||
|
||||
let seat: wl_seat::WlSeat = globals.bind(&qh, 1..=8, ())?;
|
||||
|
||||
match protocol {
|
||||
Protocol::Ext => {
|
||||
let manager: ExtDataControlManagerV1 = globals
|
||||
.bind(&qh, 1..=1, ())
|
||||
.map_err(|e| anyhow::anyhow!("Could not bind ext_data_control_manager_v1: {e}"))?;
|
||||
let _device = manager.get_data_device(&seat, &qh, ());
|
||||
// Roundtrip to ensure the device creation is acknowledged and the
|
||||
// initial selection offer (if any) is delivered.
|
||||
queue.roundtrip(&mut state)?;
|
||||
}
|
||||
Protocol::Wlr => {
|
||||
let manager: ZwlrDataControlManagerV1 = globals
|
||||
.bind(&qh, 2..=2, ())
|
||||
.map_err(|e| anyhow::anyhow!("Could not bind zwlr_data_control_manager_v1: {e}"))?;
|
||||
let _device = manager.get_data_device(&seat, &qh, ());
|
||||
queue.roundtrip(&mut state)?;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Clipboard monitor event loop running");
|
||||
|
||||
// Main event loop: blocks until an event is available, then dispatches it.
|
||||
loop {
|
||||
if let Err(e) = queue.blocking_dispatch(&mut state) {
|
||||
tracing::error!("Wayland dispatch error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
42
strata-daemon/src/clipboard/writer.rs
Normal file
42
strata-daemon/src/clipboard/writer.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/// Writes content back to the Wayland clipboard using wl-clipboard-rs.
|
||||
/// `copy_multi(foreground: false)` spawns a background OS thread that serves
|
||||
/// `wl_data_source.send` requests from other apps - the tokio runtime is
|
||||
/// never blocked.
|
||||
use anyhow::Result;
|
||||
use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WriteRequest {
|
||||
pub mime_type: String,
|
||||
pub content: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn write_to_clipboard(req: WriteRequest) -> Result<()> {
|
||||
let mime = req.mime_type.clone();
|
||||
let bytes = req.content.clone();
|
||||
|
||||
// For text, offer both the specific type and text/plain for compatibility.
|
||||
let sources: Vec<MimeSource> = if mime.starts_with("text/") {
|
||||
vec![
|
||||
MimeSource {
|
||||
source: Source::Bytes(bytes.clone().into()),
|
||||
mime_type: MimeType::Specific(mime.clone()),
|
||||
},
|
||||
MimeSource {
|
||||
source: Source::Bytes(bytes.clone().into()),
|
||||
mime_type: MimeType::Specific("text/plain;charset=utf-8".into()),
|
||||
},
|
||||
]
|
||||
} else {
|
||||
vec![MimeSource {
|
||||
source: Source::Bytes(bytes.into()),
|
||||
mime_type: MimeType::Specific(mime),
|
||||
}]
|
||||
};
|
||||
|
||||
let mut opts = Options::new();
|
||||
opts.foreground(false);
|
||||
opts.copy_multi(sources)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
23
strata-daemon/src/config.rs
Normal file
23
strata-daemon/src/config.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
pub struct Config {
|
||||
pub db_path: PathBuf,
|
||||
pub max_history: usize,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn new() -> Self {
|
||||
let data_dir = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("strata");
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(&data_dir) {
|
||||
tracing::warn!("Could not create data dir {:?}: {}", data_dir, e);
|
||||
}
|
||||
|
||||
Self {
|
||||
db_path: data_dir.join("clipboard.db"),
|
||||
max_history: 200,
|
||||
}
|
||||
}
|
||||
}
|
||||
336
strata-daemon/src/db.rs
Normal file
336
strata-daemon/src/db.rs
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
use anyhow::{Context, Result};
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// Metadata for a clipboard entry - does NOT include thumbnail bytes.
|
||||
/// Thumbnails are fetched separately via `get_thumbnail` for lazy loading.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ItemMeta {
|
||||
pub id: String,
|
||||
pub mime_type: String,
|
||||
/// Non-null for text types (truncated preview for display).
|
||||
pub content_text: Option<String>,
|
||||
pub source_app: Option<String>,
|
||||
pub created_at: i64,
|
||||
/// True if this item has a stored thumbnail (clients should call get_thumbnail to fetch it).
|
||||
pub has_thumbnail: bool,
|
||||
}
|
||||
|
||||
/// A row as stored in SQLite (blob is raw bytes, not base64).
|
||||
#[allow(dead_code)]
|
||||
pub struct RawItem {
|
||||
pub id: String,
|
||||
pub mime_type: String,
|
||||
pub content_text: Option<String>,
|
||||
pub content_blob: Option<Vec<u8>>,
|
||||
pub thumbnail_blob: Option<Vec<u8>>,
|
||||
pub source_app: Option<String>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
pub struct Db {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
/// Acquire the DB lock, transparently recovering from poison.
|
||||
///
|
||||
/// A mutex is "poisoned" when a thread panics while holding it. For our SQLite
|
||||
/// connection this is safe to recover from because rusqlite uses RAII statements
|
||||
/// and transactions - any in-flight statement or transaction has already been
|
||||
/// rolled back by the time the panic unwinds past it. Without this recovery,
|
||||
/// a single panic anywhere in the daemon would permanently break ALL future
|
||||
/// database access (every subsequent .lock().unwrap() would panic too).
|
||||
fn lock_conn(m: &Mutex<Connection>) -> MutexGuard<'_, Connection> {
|
||||
match m.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => {
|
||||
tracing::warn!("DB mutex was poisoned; recovering");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = Connection::open(path)
|
||||
.with_context(|| format!("Opening SQLite database at {:?}", path))?;
|
||||
|
||||
// Performance pragmas: WAL mode for concurrent reads, synchronous=NORMAL is safe
|
||||
// for a clipboard history (we can tolerate losing the last item on a crash).
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = ON;",
|
||||
)?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS clipboard_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
mime_type TEXT NOT NULL,
|
||||
content_text TEXT,
|
||||
content_blob BLOB,
|
||||
thumbnail_blob BLOB,
|
||||
content_hash TEXT NOT NULL,
|
||||
source_app TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_history (created_at DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_hash ON clipboard_history (content_hash);",
|
||||
)?;
|
||||
|
||||
// Migration: add thumbnail_blob column to existing databases.
|
||||
conn.execute_batch("ALTER TABLE clipboard_history ADD COLUMN thumbnail_blob BLOB;")
|
||||
.ok(); // Silently ignore error if column already exists.
|
||||
|
||||
// FTS5 full-text search index over content_text only.
|
||||
// Images and other non-text items are NOT indexed - search filters
|
||||
// them out (an empty search shows everything; a non-empty search
|
||||
// shows only matching text items).
|
||||
//
|
||||
// External-content table linked by rowid avoids duplicating the text.
|
||||
// unicode61 with diacritic removal gives reasonable matching for
|
||||
// European languages (e.g. searching "cafe" finds "café").
|
||||
conn.execute_batch(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS clipboard_fts USING fts5(
|
||||
content_text,
|
||||
content='clipboard_history',
|
||||
content_rowid='rowid',
|
||||
tokenize='unicode61 remove_diacritics 2'
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS clipboard_ai AFTER INSERT ON clipboard_history BEGIN
|
||||
INSERT INTO clipboard_fts(rowid, content_text)
|
||||
VALUES (new.rowid, new.content_text);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS clipboard_ad AFTER DELETE ON clipboard_history BEGIN
|
||||
INSERT INTO clipboard_fts(clipboard_fts, rowid, content_text)
|
||||
VALUES('delete', old.rowid, old.content_text);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS clipboard_au AFTER UPDATE ON clipboard_history BEGIN
|
||||
INSERT INTO clipboard_fts(clipboard_fts, rowid, content_text)
|
||||
VALUES('delete', old.rowid, old.content_text);
|
||||
INSERT INTO clipboard_fts(rowid, content_text)
|
||||
VALUES (new.rowid, new.content_text);
|
||||
END;",
|
||||
)?;
|
||||
|
||||
// Backfill FTS index from any pre-existing rows. Cheap no-op if already in sync.
|
||||
let _ = conn.execute_batch("INSERT INTO clipboard_fts(clipboard_fts) VALUES('rebuild');");
|
||||
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert or promote (bump `created_at`) a duplicate item.
|
||||
/// Returns `(id, is_new)`.
|
||||
pub fn upsert_item(
|
||||
&self,
|
||||
mime_type: &str,
|
||||
content_text: Option<&str>,
|
||||
content_blob: Option<&[u8]>,
|
||||
thumbnail_blob: Option<&[u8]>,
|
||||
content_hash: &str,
|
||||
source_app: Option<&str>,
|
||||
) -> Result<(String, bool)> {
|
||||
let conn = lock_conn(&self.conn);
|
||||
let now_ms = chrono_now_ms();
|
||||
|
||||
// Check for existing item with this hash.
|
||||
let existing: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM clipboard_history WHERE content_hash = ?1",
|
||||
params![content_hash],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some(id) = existing {
|
||||
conn.execute(
|
||||
"UPDATE clipboard_history SET created_at = ?1 WHERE id = ?2",
|
||||
params![now_ms, id],
|
||||
)?;
|
||||
return Ok((id, false));
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO clipboard_history
|
||||
(id, mime_type, content_text, content_blob, thumbnail_blob, content_hash, source_app, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![id, mime_type, content_text, content_blob, thumbnail_blob, content_hash, source_app, now_ms],
|
||||
)?;
|
||||
Ok((id, true))
|
||||
}
|
||||
|
||||
/// Return a page of recent items as metadata (no thumbnail bytes).
|
||||
/// `offset` is from the most recent item (0 = newest).
|
||||
pub fn get_history_page(
|
||||
&self,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
max_history: usize,
|
||||
) -> Result<Vec<ItemMeta>> {
|
||||
let conn = lock_conn(&self.conn);
|
||||
let effective_limit = limit.min(max_history.saturating_sub(offset));
|
||||
if effective_limit == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, mime_type, content_text, source_app, created_at,
|
||||
thumbnail_blob IS NOT NULL AS has_thumb
|
||||
FROM clipboard_history
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?1 OFFSET ?2",
|
||||
)?;
|
||||
|
||||
let items = stmt
|
||||
.query_map(params![effective_limit as i64, offset as i64], |row| {
|
||||
Ok(ItemMeta {
|
||||
id: row.get(0)?,
|
||||
mime_type: row.get(1)?,
|
||||
content_text: row.get(2)?,
|
||||
source_app: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
has_thumbnail: row.get::<_, i64>(5)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Full-text search over content_text. Only text items can match - images
|
||||
/// and other non-text content are NOT indexed and never appear in results.
|
||||
/// Empty query returns []; callers should use get_history_page for that.
|
||||
pub fn search_history(&self, query: &str, limit: usize) -> Result<Vec<ItemMeta>> {
|
||||
let conn = lock_conn(&self.conn);
|
||||
|
||||
// Build a safe FTS5 prefix query: each token quoted + '*' suffix for prefix match.
|
||||
let tokens: Vec<String> = query
|
||||
.split_whitespace()
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(|t| {
|
||||
// Escape embedded double-quotes by doubling them per FTS5 syntax.
|
||||
let escaped = t.replace('"', "\"\"");
|
||||
format!("\"{}\"*", escaped)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if tokens.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let fts_query = tokens.join(" ");
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT h.id, h.mime_type, h.content_text, h.source_app, h.created_at,
|
||||
h.thumbnail_blob IS NOT NULL AS has_thumb
|
||||
FROM clipboard_history h
|
||||
WHERE h.rowid IN (SELECT rowid FROM clipboard_fts WHERE clipboard_fts MATCH ?1)
|
||||
ORDER BY h.created_at DESC
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
|
||||
let items = stmt
|
||||
.query_map(params![fts_query, limit as i64], |row| {
|
||||
Ok(ItemMeta {
|
||||
id: row.get(0)?,
|
||||
mime_type: row.get(1)?,
|
||||
content_text: row.get(2)?,
|
||||
source_app: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
has_thumbnail: row.get::<_, i64>(5)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Fetch a single item's thumbnail bytes. Returns None if the item has no thumbnail
|
||||
/// (text item, or doesn't exist).
|
||||
pub fn get_thumbnail(&self, id: &str) -> Result<Option<Vec<u8>>> {
|
||||
let conn = lock_conn(&self.conn);
|
||||
let blob: Option<Option<Vec<u8>>> = conn
|
||||
.query_row(
|
||||
"SELECT thumbnail_blob FROM clipboard_history WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
Ok(blob.flatten())
|
||||
}
|
||||
|
||||
/// Fetch raw item content for clipboard write-back.
|
||||
pub fn get_raw_item(&self, id: &str) -> Result<Option<RawItem>> {
|
||||
let conn = lock_conn(&self.conn);
|
||||
let result = conn.query_row(
|
||||
"SELECT id, mime_type, content_text, content_blob, thumbnail_blob, source_app, created_at
|
||||
FROM clipboard_history WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(RawItem {
|
||||
id: row.get(0)?,
|
||||
mime_type: row.get(1)?,
|
||||
content_text: row.get(2)?,
|
||||
content_blob: row.get(3)?,
|
||||
thumbnail_blob: row.get(4)?,
|
||||
source_app: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
})
|
||||
},
|
||||
).ok();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn delete_item(&self, id: &str) -> Result<bool> {
|
||||
let conn = lock_conn(&self.conn);
|
||||
let n = conn.execute("DELETE FROM clipboard_history WHERE id = ?1", params![id])?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
pub fn clear_history(&self) -> Result<()> {
|
||||
let conn = lock_conn(&self.conn);
|
||||
conn.execute_batch("DELETE FROM clipboard_history;")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prune history to `max_history` most recent items. Returns the IDs of
|
||||
/// items that were deleted, so the caller can emit ItemDeleted signals
|
||||
/// (lets clients clean up per-item caches like thumbnail files).
|
||||
pub fn prune(&self, max_history: usize) -> Result<Vec<String>> {
|
||||
let conn = lock_conn(&self.conn);
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM clipboard_history", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
if (count as usize) <= max_history {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id FROM clipboard_history
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM clipboard_history ORDER BY created_at DESC LIMIT ?1
|
||||
)",
|
||||
)?;
|
||||
let ids: Vec<String> = stmt
|
||||
.query_map(params![max_history as i64], |row| row.get::<_, String>(0))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
conn.execute(
|
||||
"DELETE FROM clipboard_history
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM clipboard_history ORDER BY created_at DESC LIMIT ?1
|
||||
)",
|
||||
params![max_history as i64],
|
||||
)?;
|
||||
Ok(ids)
|
||||
}
|
||||
}
|
||||
|
||||
fn chrono_now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
272
strata-daemon/src/dbus_service.rs
Normal file
272
strata-daemon/src/dbus_service.rs
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine as _;
|
||||
use zbus::{interface, object_server::SignalContext};
|
||||
|
||||
use crate::db::{Db, RawItem};
|
||||
|
||||
/// Maximum thumbnail dimension for images (px). Larger images are resized down.
|
||||
pub const THUMB_PX: u32 = 200;
|
||||
/// Default maximum raw image size to accept (bytes). Overridable via SetConfig.
|
||||
pub const DEFAULT_MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
|
||||
/// Default maximum text size to accept. Overridable via SetConfig.
|
||||
pub const DEFAULT_MAX_TEXT_BYTES: usize = 1024 * 1024;
|
||||
/// Default maximum history item count. Overridable via SetConfig.
|
||||
pub const DEFAULT_MAX_HISTORY: usize = 200;
|
||||
|
||||
/// Runtime-configurable limits shared between the D-Bus service and the
|
||||
/// clipboard processing tasks. All three are read on every ingest / prune
|
||||
/// call, so updates via SetConfig take effect immediately.
|
||||
#[derive(Clone)]
|
||||
pub struct Limits {
|
||||
pub max_history: Arc<AtomicUsize>,
|
||||
pub max_text_bytes: Arc<AtomicUsize>,
|
||||
pub max_image_bytes: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
pub fn with_defaults() -> Self {
|
||||
Self {
|
||||
max_history: Arc::new(AtomicUsize::new(DEFAULT_MAX_HISTORY)),
|
||||
max_text_bytes: Arc::new(AtomicUsize::new(DEFAULT_MAX_TEXT_BYTES)),
|
||||
max_image_bytes: Arc::new(AtomicUsize::new(DEFAULT_MAX_IMAGE_BYTES)),
|
||||
}
|
||||
}
|
||||
pub fn max_history(&self) -> usize {
|
||||
self.max_history.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn max_text_bytes(&self) -> usize {
|
||||
self.max_text_bytes.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn max_image_bytes(&self) -> usize {
|
||||
self.max_image_bytes.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Submitted by the GJS extension via D-Bus (Meta.Selection path).
|
||||
pub struct SubmitRequest {
|
||||
pub mime_type: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct StrataManager {
|
||||
pub db: Arc<Db>,
|
||||
pub limits: Limits,
|
||||
pub focused_app: Arc<tokio::sync::Mutex<String>>,
|
||||
pub submit_tx: tokio::sync::mpsc::UnboundedSender<SubmitRequest>,
|
||||
/// Send a `()` here to trigger graceful shutdown from the D-Bus Shutdown method.
|
||||
pub shutdown_tx: tokio::sync::mpsc::UnboundedSender<()>,
|
||||
}
|
||||
|
||||
#[interface(name = "org.gnome.Strata.Manager")]
|
||||
impl StrataManager {
|
||||
/// Called by the GJS extension when it detects a clipboard change via Meta.Selection.
|
||||
/// `content` is the raw bytes of the clipboard payload (D-Bus `ay`).
|
||||
async fn submit_item(&self, mime_type: String, content: Vec<u8>) -> zbus::fdo::Result<()> {
|
||||
self.submit_tx
|
||||
.send(SubmitRequest {
|
||||
mime_type,
|
||||
bytes: content,
|
||||
})
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
||||
}
|
||||
/// Return a page of recent items as JSON metadata (no inline thumbnail bytes).
|
||||
/// Clients call GetThumbnail(id) lazily for items with has_thumbnail=true.
|
||||
/// `offset` is from the most recent item (0 = newest).
|
||||
async fn get_history(&self, offset: u32, limit: u32) -> zbus::fdo::Result<String> {
|
||||
let db = self.db.clone();
|
||||
let max = self.limits.max_history();
|
||||
let items = tokio::task::spawn_blocking(move || {
|
||||
db.get_history_page(offset as usize, limit as usize, max)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
|
||||
|
||||
serde_json::to_string(&items).map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
/// Full-text search across the entire DB. Empty query returns []; callers
|
||||
/// should fall back to GetHistory in that case.
|
||||
async fn search_history(&self, query: String, limit: u32) -> zbus::fdo::Result<String> {
|
||||
let db = self.db.clone();
|
||||
let items = tokio::task::spawn_blocking(move || db.search_history(&query, limit as usize))
|
||||
.await
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
|
||||
|
||||
serde_json::to_string(&items).map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
/// Fetch a thumbnail's raw PNG bytes. Returns empty array if the item has no
|
||||
/// thumbnail or doesn't exist (clients can fall back to a placeholder).
|
||||
async fn get_thumbnail(&self, id: String) -> zbus::fdo::Result<Vec<u8>> {
|
||||
let db = self.db.clone();
|
||||
let bytes = tokio::task::spawn_blocking(move || db.get_thumbnail(&id))
|
||||
.await
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
|
||||
Ok(bytes.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Return the raw content of a clipboard item for GJS to write to clipboard.
|
||||
/// GJS uses St.Clipboard (text) or Meta.SelectionSourceMemory (images)
|
||||
/// because the Wayland data-control protocol is not accessible from non-compositor processes.
|
||||
async fn get_item_content(&self, id: String) -> zbus::fdo::Result<(String, String)> {
|
||||
let db = self.db.clone();
|
||||
let item: Option<RawItem> = tokio::task::spawn_blocking(move || db.get_raw_item(&id))
|
||||
.await
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
|
||||
|
||||
let item = item.ok_or_else(|| zbus::fdo::Error::Failed("Item not found".into()))?;
|
||||
|
||||
let content_b64 = if let Some(text) = item.content_text {
|
||||
base64::engine::general_purpose::STANDARD.encode(text.as_bytes())
|
||||
} else if let Some(blob) = item.content_blob {
|
||||
base64::engine::general_purpose::STANDARD.encode(&blob)
|
||||
} else {
|
||||
return Err(zbus::fdo::Error::Failed("Item has no content".into()));
|
||||
};
|
||||
|
||||
Ok((item.mime_type, content_b64))
|
||||
}
|
||||
|
||||
/// Restore a clipboard item to the system clipboard.
|
||||
async fn set_clipboard(&self, id: String) -> zbus::fdo::Result<()> {
|
||||
let db = self.db.clone();
|
||||
let item: Option<RawItem> = tokio::task::spawn_blocking(move || db.get_raw_item(&id))
|
||||
.await
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
|
||||
|
||||
let item = item.ok_or_else(|| zbus::fdo::Error::Failed("Item not found".into()))?;
|
||||
|
||||
let content = if let Some(text) = item.content_text {
|
||||
crate::clipboard::writer::WriteRequest {
|
||||
mime_type: item.mime_type,
|
||||
content: text.into_bytes(),
|
||||
}
|
||||
} else if let Some(blob) = item.content_blob {
|
||||
crate::clipboard::writer::WriteRequest {
|
||||
mime_type: item.mime_type,
|
||||
content: blob,
|
||||
}
|
||||
} else {
|
||||
return Err(zbus::fdo::Error::Failed("Item has no content".into()));
|
||||
};
|
||||
|
||||
tokio::task::spawn_blocking(move || crate::clipboard::writer::write_to_clipboard(content))
|
||||
.await
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
/// Remove a single item from history.
|
||||
async fn delete_item(
|
||||
&self,
|
||||
id: String,
|
||||
#[zbus(signal_context)] ctx: SignalContext<'_>,
|
||||
) -> zbus::fdo::Result<()> {
|
||||
let db = self.db.clone();
|
||||
let id_clone = id.clone();
|
||||
tokio::task::spawn_blocking(move || db.delete_item(&id_clone))
|
||||
.await
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
|
||||
|
||||
Self::item_deleted(&ctx, &id)
|
||||
.await
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
/// Remove all items from history.
|
||||
async fn clear_history(
|
||||
&self,
|
||||
#[zbus(signal_context)] ctx: SignalContext<'_>,
|
||||
) -> zbus::fdo::Result<()> {
|
||||
let db = self.db.clone();
|
||||
tokio::task::spawn_blocking(move || db.clear_history())
|
||||
.await
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
|
||||
|
||||
Self::history_cleared(&ctx)
|
||||
.await
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
/// Called by the GJS extension on every focused-window change.
|
||||
/// Used for app exclusion: if the focused app is in the exclusion list,
|
||||
/// the extension will immediately DeleteItem any new item from that app.
|
||||
async fn set_focused_app(&self, app_id: String) {
|
||||
*self.focused_app.lock().await = app_id;
|
||||
}
|
||||
|
||||
/// Push runtime limits from the front-end. Takes effect immediately.
|
||||
/// All three values are absolute (max_text and max_image are bytes,
|
||||
/// not MB). 0 means "leave unchanged" for that field.
|
||||
async fn set_config(
|
||||
&self,
|
||||
max_history: u32,
|
||||
max_text_bytes: u32,
|
||||
max_image_bytes: u32,
|
||||
) -> zbus::fdo::Result<()> {
|
||||
use std::sync::atomic::Ordering;
|
||||
if max_history > 0 {
|
||||
self.limits
|
||||
.max_history
|
||||
.store(max_history as usize, Ordering::Relaxed);
|
||||
}
|
||||
if max_text_bytes > 0 {
|
||||
self.limits
|
||||
.max_text_bytes
|
||||
.store(max_text_bytes as usize, Ordering::Relaxed);
|
||||
}
|
||||
if max_image_bytes > 0 {
|
||||
self.limits
|
||||
.max_image_bytes
|
||||
.store(max_image_bytes as usize, Ordering::Relaxed);
|
||||
}
|
||||
tracing::info!(
|
||||
"SetConfig: max_history={}, max_text={} B, max_image={} B",
|
||||
self.limits.max_history(),
|
||||
self.limits.max_text_bytes(),
|
||||
self.limits.max_image_bytes(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gracefully shut down the daemon.
|
||||
async fn shutdown(&self) {
|
||||
tracing::info!("Shutdown requested via D-Bus");
|
||||
// Signal the main loop to exit. The reply is sent before the process exits
|
||||
// because we return normally from this method; the main loop exits shortly after.
|
||||
let _ = self.shutdown_tx.send(());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Signals
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// Emitted when a new item is stored (after dedup + thumbnail generation).
|
||||
/// `preview` is a text excerpt (≤120 chars) for text items, or empty for
|
||||
/// images and other binary types - clients should call GetThumbnail(id) to
|
||||
/// fetch image thumbnails lazily.
|
||||
#[zbus(signal)]
|
||||
pub async fn item_added(
|
||||
ctx: &SignalContext<'_>,
|
||||
id: &str,
|
||||
mime_type: &str,
|
||||
preview: &str,
|
||||
) -> zbus::Result<()>;
|
||||
|
||||
/// Emitted when an item is removed.
|
||||
#[zbus(signal)]
|
||||
pub async fn item_deleted(ctx: &SignalContext<'_>, id: &str) -> zbus::Result<()>;
|
||||
|
||||
/// Emitted when the entire history is cleared.
|
||||
#[zbus(signal)]
|
||||
pub async fn history_cleared(ctx: &SignalContext<'_>) -> zbus::Result<()>;
|
||||
}
|
||||
363
strata-daemon/src/main.rs
Normal file
363
strata-daemon/src/main.rs
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
mod clipboard;
|
||||
mod config;
|
||||
mod db;
|
||||
mod dbus_service;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine as _;
|
||||
use dbus_service::{Limits, StrataManager, SubmitRequest, THUMB_PX};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use wl_clipboard_rs::paste::{get_contents, ClipboardType, MimeType, Seat};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
tracing::info!("Strata daemon starting");
|
||||
|
||||
let cfg = config::Config::new();
|
||||
let db = Arc::new(db::Db::open(&cfg.db_path).context("Opening database")?);
|
||||
let focused_app = Arc::new(Mutex::new(String::new()));
|
||||
let limits = Limits::with_defaults();
|
||||
limits
|
||||
.max_history
|
||||
.store(cfg.max_history, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Channels
|
||||
// -----------------------------------------------------------------------
|
||||
// Wayland-monitor path: daemon reads clipboard bytes itself.
|
||||
let (clip_tx, mut clip_rx) = mpsc::unbounded_channel::<clipboard::monitor::ClipboardChange>();
|
||||
// GJS submit path: GJS reads via Meta.Selection, sends bytes over D-Bus.
|
||||
let (submit_tx, mut submit_rx) = mpsc::unbounded_channel::<SubmitRequest>();
|
||||
// D-Bus Shutdown method path.
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::unbounded_channel::<()>();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// D-Bus service
|
||||
// -----------------------------------------------------------------------
|
||||
let manager = StrataManager {
|
||||
db: db.clone(),
|
||||
limits: limits.clone(),
|
||||
focused_app: focused_app.clone(),
|
||||
submit_tx,
|
||||
shutdown_tx,
|
||||
};
|
||||
|
||||
let dbus_conn = zbus::connection::Builder::session()?
|
||||
.serve_at("/org/gnome/Strata", manager)?
|
||||
.build()
|
||||
.await
|
||||
.context("Building D-Bus connection")?;
|
||||
|
||||
// Request the well-known name with ReplaceExisting so a new instance always
|
||||
// wins the race against the outgoing instance during extension reload.
|
||||
dbus_conn
|
||||
.request_name_with_flags(
|
||||
"org.gnome.Strata",
|
||||
zbus::fdo::RequestNameFlags::ReplaceExisting.into(),
|
||||
)
|
||||
.await
|
||||
.context("Acquiring D-Bus name org.gnome.Strata")?;
|
||||
|
||||
tracing::info!("D-Bus service registered as org.gnome.Strata");
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Clipboard monitor (Wayland protocols - optional, soft-fail on GNOME)
|
||||
// GJS provides clipboard content via SubmitItem when this is unavailable.
|
||||
// -----------------------------------------------------------------------
|
||||
match clipboard::monitor::spawn(clip_tx) {
|
||||
Ok(()) => tracing::info!("Wayland clipboard monitor started"),
|
||||
Err(_) => tracing::info!(
|
||||
"Wayland clipboard monitor unavailable, using GJS Meta.Selection path (expected on GNOME)"
|
||||
),
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Clipboard processing tasks
|
||||
// -----------------------------------------------------------------------
|
||||
let db_clone = db.clone();
|
||||
let conn_clone = dbus_conn.clone();
|
||||
let limits_clone = limits.clone();
|
||||
|
||||
// Wayland-monitor path.
|
||||
tokio::spawn(async move {
|
||||
while let Some(change) = clip_rx.recv().await {
|
||||
if let Err(e) =
|
||||
process_change(&change.mime_types, &db_clone, &conn_clone, &limits_clone).await
|
||||
{
|
||||
tracing::warn!("Error processing Wayland clipboard change: {e:#}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let db_submit = db.clone();
|
||||
let conn_submit = dbus_conn.clone();
|
||||
let limits_submit = limits.clone();
|
||||
|
||||
// GJS submit path.
|
||||
tokio::spawn(async move {
|
||||
while let Some(req) = submit_rx.recv().await {
|
||||
if let Err(e) = process_bytes(
|
||||
req.mime_type,
|
||||
req.bytes,
|
||||
&db_submit,
|
||||
&conn_submit,
|
||||
&limits_submit,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Error processing submitted clipboard item: {e:#}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Graceful shutdown on SIGTERM / SIGINT
|
||||
// -----------------------------------------------------------------------
|
||||
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
|
||||
|
||||
tokio::select! {
|
||||
_ = sigterm.recv() => { tracing::info!("Received SIGTERM, shutting down"); }
|
||||
_ = sigint.recv() => { tracing::info!("Received SIGINT, shutting down"); }
|
||||
_ = shutdown_rx.recv() => { tracing::info!("Shutdown via D-Bus, shutting down"); }
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Select the best MIME type to store from a list of offered types.
|
||||
///
|
||||
/// Priority: images > plain text > rich text (html/rtf) > file URIs.
|
||||
///
|
||||
/// Plain text is intentionally ranked above text/html and text/rtf so that
|
||||
/// when an application (e.g. a code editor or web browser) advertises both a
|
||||
/// rich-text variant and a plain-text variant, we capture the plain text. This
|
||||
/// avoids storing syntax-highlighted HTML markup instead of source code.
|
||||
/// Rich-text types are still captured when no plain-text alternative is offered.
|
||||
fn pick_mime(mimes: &[String]) -> Option<&str> {
|
||||
const PREFERRED: &[&str] = &[
|
||||
// Raster images (size-capped at MAX_IMAGE_BYTES).
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
"image/avif",
|
||||
"image/x-icon",
|
||||
"image/svg+xml",
|
||||
// Plain text (UTF-8 preferred, then locale, then X11 legacy aliases).
|
||||
// Ranked above rich-text so editors that expose both text/plain and
|
||||
// text/html give us the raw source, not styled markup.
|
||||
"text/plain;charset=utf-8",
|
||||
"UTF8_STRING",
|
||||
"text/plain",
|
||||
"STRING",
|
||||
"TEXT",
|
||||
// Rich text -- only reached when no plain-text offer is available.
|
||||
"text/html",
|
||||
"text/rtf",
|
||||
"application/rtf",
|
||||
"text/markdown",
|
||||
// File-manager copy/cut payloads (URI list, not bytes).
|
||||
"x-special/gnome-copied-files",
|
||||
"x-special/nautilus-clipboard",
|
||||
"application/x-kde-cutselection",
|
||||
"text/uri-list",
|
||||
];
|
||||
// Allowlist only: accepting arbitrary mimes would force us to read
|
||||
// potentially huge payloads (1 GB binaries, etc.) before we could
|
||||
// size-check them. Unknown mimes are silently dropped.
|
||||
PREFERRED
|
||||
.iter()
|
||||
.find(|&&want| mimes.iter().any(|m| m == want))
|
||||
.copied()
|
||||
}
|
||||
|
||||
async fn process_change(
|
||||
mime_types: &[String],
|
||||
db: &Arc<db::Db>,
|
||||
conn: &zbus::Connection,
|
||||
limits: &Limits,
|
||||
) -> Result<()> {
|
||||
// Password managers (KeePassXC, KeePass, some Bitwarden builds) mark
|
||||
// copied secrets with this mime hint. Skip the change entirely so we
|
||||
// never store the password.
|
||||
if mime_types.iter().any(|m| m == "x-kde-passwordManagerHint") {
|
||||
tracing::debug!("Skipping clipboard change marked as sensitive");
|
||||
return Ok(());
|
||||
}
|
||||
let mime = match pick_mime(mime_types) {
|
||||
Some(m) => m.to_string(),
|
||||
None => {
|
||||
tracing::debug!("No usable MIME type in {:?}", mime_types);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!("Processing Wayland clipboard change, MIME: {}", mime);
|
||||
|
||||
let mime_clone = mime.clone();
|
||||
let (raw_bytes, actual_mime) =
|
||||
tokio::task::spawn_blocking(move || read_clipboard_bytes(&mime_clone))
|
||||
.await?
|
||||
.context("Reading clipboard content")?;
|
||||
|
||||
if raw_bytes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
process_bytes(actual_mime, raw_bytes, db, conn, limits).await
|
||||
}
|
||||
|
||||
/// Core storage path - shared by the Wayland-monitor and GJS-submit paths.
|
||||
async fn process_bytes(
|
||||
mime: String,
|
||||
raw_bytes: Vec<u8>,
|
||||
db: &Arc<db::Db>,
|
||||
conn: &zbus::Connection,
|
||||
limits: &Limits,
|
||||
) -> Result<()> {
|
||||
if raw_bytes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let is_image = mime.starts_with("image/");
|
||||
|
||||
// Enforce size limits
|
||||
let max = if is_image {
|
||||
limits.max_image_bytes()
|
||||
} else {
|
||||
limits.max_text_bytes()
|
||||
};
|
||||
if raw_bytes.len() > max {
|
||||
tracing::debug!(
|
||||
"Clipboard item too large ({} bytes, limit {}), skipping",
|
||||
raw_bytes.len(),
|
||||
max
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Compute content hash for deduplication
|
||||
let hash = blake3::hash(&raw_bytes).to_hex().to_string();
|
||||
|
||||
// For images: generate a thumbnail in spawn_blocking; store original as blob, thumbnail separately.
|
||||
// For text: store as UTF-8 string.
|
||||
// The `preview` string emitted with the ItemAdded signal is now ALWAYS a text excerpt;
|
||||
// image thumbnails are fetched lazily by clients via GetThumbnail(id) to keep the
|
||||
// signal payload small (and consistent with paginated GetHistory results).
|
||||
let (content_text, content_blob, thumbnail_blob, preview) = if is_image {
|
||||
let raw_for_thumb = raw_bytes.clone();
|
||||
let thumb_result =
|
||||
tokio::task::spawn_blocking(move || make_thumbnail(&raw_for_thumb, THUMB_PX)).await?;
|
||||
|
||||
let thumb_bytes = match thumb_result {
|
||||
Ok((bytes, _b64)) => bytes,
|
||||
Err(e) => {
|
||||
tracing::warn!("Image thumbnail generation failed: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
(None, Some(raw_bytes), Some(thumb_bytes), String::new())
|
||||
} else {
|
||||
let text = String::from_utf8_lossy(&raw_bytes).into_owned();
|
||||
let preview: String = text.chars().take(120).collect();
|
||||
(Some(text), None, None, preview)
|
||||
};
|
||||
|
||||
let db_clone = db.clone();
|
||||
let mime_clone = mime.clone();
|
||||
let hash_clone = hash.clone();
|
||||
|
||||
let (id, is_new) = tokio::task::spawn_blocking(move || {
|
||||
db_clone.upsert_item(
|
||||
&mime_clone,
|
||||
content_text.as_deref(),
|
||||
content_blob.as_deref(),
|
||||
thumbnail_blob.as_deref(),
|
||||
&hash_clone,
|
||||
None, // source_app is set via SetFocusedApp
|
||||
)
|
||||
})
|
||||
.await?
|
||||
.context("Upserting clipboard item")?;
|
||||
|
||||
// Prune old items to stay within max_history limit.
|
||||
// Only needed when a genuinely new item was just inserted.
|
||||
let pruned_ids: Vec<String> = if is_new {
|
||||
let db_prune = db.clone();
|
||||
let max_history = limits.max_history();
|
||||
tokio::task::spawn_blocking(move || db_prune.prune(max_history))
|
||||
.await?
|
||||
.context("Pruning history")?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
if is_new {
|
||||
let iface = conn
|
||||
.object_server()
|
||||
.interface::<_, StrataManager>("/org/gnome/Strata")
|
||||
.await
|
||||
.context("Getting StrataManager interface ref")?;
|
||||
|
||||
StrataManager::item_added(iface.signal_context(), &id, &mime, &preview)
|
||||
.await
|
||||
.context("Emitting ItemAdded signal")?;
|
||||
|
||||
for pid in &pruned_ids {
|
||||
if let Err(e) = StrataManager::item_deleted(iface.signal_context(), pid).await {
|
||||
tracing::warn!("Emitting ItemDeleted for pruned id={}: {}", pid, e);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("Stored new clipboard item id={} mime={}", id, mime);
|
||||
} else {
|
||||
tracing::debug!("Promoted duplicate clipboard item id={}", id);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read clipboard content using wl-clipboard-rs (opens its own Wayland connection).
|
||||
/// Returns `(bytes, actual_mime_type)`.
|
||||
fn read_clipboard_bytes(mime: &str) -> Result<(Vec<u8>, String)> {
|
||||
use std::io::Read;
|
||||
|
||||
let (mut reader, actual_mime) = get_contents(
|
||||
ClipboardType::Regular,
|
||||
Seat::Unspecified,
|
||||
MimeType::Specific(mime),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("wl-clipboard-rs read error: {e:?}"))?;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
reader.read_to_end(&mut buf)?;
|
||||
Ok((buf, actual_mime))
|
||||
}
|
||||
|
||||
/// Generate a PNG thumbnail (≤`max_px`×`max_px`) from raw image bytes.
|
||||
/// Returns `(thumbnail_bytes, base64_preview)`.
|
||||
fn make_thumbnail(raw: &[u8], max_px: u32) -> Result<(Vec<u8>, String)> {
|
||||
let img = image::load_from_memory(raw).context("Decoding image")?;
|
||||
|
||||
let thumb = img.thumbnail(max_px, max_px);
|
||||
let mut out = Vec::new();
|
||||
thumb
|
||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.context("Encoding thumbnail")?;
|
||||
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&out);
|
||||
Ok((out, b64))
|
||||
}
|
||||
84
strata@edu4rdshl.dev/README.md
Normal file
84
strata@edu4rdshl.dev/README.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Strata GNOME Shell extension
|
||||
|
||||
The GJS front-end for [Strata](../README.md). Renders the panel UI and forwards
|
||||
clipboard events to [`strata-daemon`](../strata-daemon/README.md) over D-Bus.
|
||||
|
||||
> The extension requires `strata-daemon` to be installed and available in
|
||||
> `$PATH`. See the [main README](../README.md) for install instructions.
|
||||
> The extension is **pure JavaScript** - no binary is bundled.
|
||||
>
|
||||
> **Compatibility:** Only tested on GNOME Shell 50. The `metadata.json`
|
||||
> declares support for 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.
|
||||
83
strata@edu4rdshl.dev/dbus.js
Normal file
83
strata@edu4rdshl.dev/dbus.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* dbus.js - Async D-Bus proxy for the Strata daemon.
|
||||
*
|
||||
* Uses Gio.DBusProxy.makeProxyWrapper to generate a typed proxy class.
|
||||
* All method calls should use the *Async variants (return Promises) to avoid
|
||||
* blocking the GNOME Shell compositor main loop.
|
||||
*/
|
||||
|
||||
import Gio from 'gi://Gio';
|
||||
|
||||
const STRATA_IFACE_XML = `
|
||||
<node>
|
||||
<interface name="org.gnome.Strata.Manager">
|
||||
|
||||
<method name="GetHistory">
|
||||
<arg type="u" direction="in" name="offset"/>
|
||||
<arg type="u" direction="in" name="limit"/>
|
||||
<arg type="s" direction="out" name="json"/>
|
||||
</method>
|
||||
|
||||
<method name="SearchHistory">
|
||||
<arg type="s" direction="in" name="query"/>
|
||||
<arg type="u" direction="in" name="limit"/>
|
||||
<arg type="s" direction="out" name="json"/>
|
||||
</method>
|
||||
|
||||
<method name="GetThumbnail">
|
||||
<arg type="s" direction="in" name="id"/>
|
||||
<arg type="ay" direction="out" name="png_bytes"/>
|
||||
</method>
|
||||
|
||||
<method name="GetItemContent">
|
||||
<arg type="s" direction="in" name="id"/>
|
||||
<arg type="s" direction="out" name="mime_type"/>
|
||||
<arg type="s" direction="out" name="content_b64"/>
|
||||
</method>
|
||||
|
||||
<method name="SetClipboard">
|
||||
<arg type="s" direction="in" name="id"/>
|
||||
</method>
|
||||
|
||||
<method name="DeleteItem">
|
||||
<arg type="s" direction="in" name="id"/>
|
||||
</method>
|
||||
|
||||
<method name="ClearHistory"/>
|
||||
|
||||
<method name="SetFocusedApp">
|
||||
<arg type="s" direction="in" name="app_id"/>
|
||||
</method>
|
||||
|
||||
<method name="Shutdown"/>
|
||||
|
||||
<method name="SetConfig">
|
||||
<arg type="u" direction="in" name="max_history"/>
|
||||
<arg type="u" direction="in" name="max_text_bytes"/>
|
||||
<arg type="u" direction="in" name="max_image_bytes"/>
|
||||
</method>
|
||||
|
||||
<method name="SubmitItem">
|
||||
<arg type="s" direction="in" name="mime_type"/>
|
||||
<arg type="ay" direction="in" name="content"/>
|
||||
</method>
|
||||
|
||||
<signal name="ItemAdded">
|
||||
<arg type="s" name="id"/>
|
||||
<arg type="s" name="mime_type"/>
|
||||
<arg type="s" name="preview"/>
|
||||
</signal>
|
||||
|
||||
<signal name="ItemDeleted">
|
||||
<arg type="s" name="id"/>
|
||||
</signal>
|
||||
|
||||
<signal name="HistoryCleared"/>
|
||||
|
||||
</interface>
|
||||
</node>`;
|
||||
|
||||
export const StrataProxy = Gio.DBusProxy.makeProxyWrapper(STRATA_IFACE_XML);
|
||||
|
||||
export const BUS_NAME = 'org.gnome.Strata';
|
||||
export const OBJECT_PATH = '/org/gnome/Strata';
|
||||
582
strata@edu4rdshl.dev/extension.js
Normal file
582
strata@edu4rdshl.dev/extension.js
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
/* extension.js - Strata extension lifecycle. */
|
||||
|
||||
import GLib from 'gi://GLib';
|
||||
import Gio from 'gi://Gio';
|
||||
import Meta from 'gi://Meta';
|
||||
import Shell from 'gi://Shell';
|
||||
import St from 'gi://St';
|
||||
|
||||
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
|
||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
|
||||
|
||||
import { StrataProxy, BUS_NAME, OBJECT_PATH } from './dbus.js';
|
||||
import { StrataPanel } from './ui/panel.js';
|
||||
|
||||
export default class StrataExtension extends Extension {
|
||||
/** @type {Gio.Subprocess | null} */
|
||||
_daemon = null;
|
||||
_daemonSpawnTime = 0;
|
||||
_daemonRestartAttempts = 0;
|
||||
_shuttingDown = false;
|
||||
_daemonRestartTimerId = null;
|
||||
|
||||
/** @type {StrataPanel | null} */
|
||||
_panel = null;
|
||||
/** @type {object | null} Gio.DBusProxy instance */
|
||||
_proxy = null;
|
||||
|
||||
_itemAddedId = null;
|
||||
_itemDeletedId = null;
|
||||
_historyClearedId = null;
|
||||
|
||||
/** @type {number | null} focused-window signal connection ID */
|
||||
_focusSignalId = null;
|
||||
|
||||
/** @type {string} WM class of the currently focused app (lower-cased) */
|
||||
_currentFocusedApp = '';
|
||||
|
||||
/** @type {object | null} PanelMenu.Button indicator */
|
||||
_indicator = null;
|
||||
|
||||
/** @type {number | null} Meta.Selection owner-changed signal ID */
|
||||
_selectionChangedId = null;
|
||||
|
||||
/** @type {number | null} Debounce timer for clipboard reads */
|
||||
_clipboardDebounceId = null;
|
||||
|
||||
/** True while a transfer_async is in flight - prevents concurrent transfers. */
|
||||
_clipboardTransferPending = false;
|
||||
|
||||
/** @type {string[]} Apps to exclude from history */
|
||||
_excludedApps = [];
|
||||
_pendingSignalId = null;
|
||||
|
||||
/** @type {boolean} Re-entrancy guard for signal processing */
|
||||
#busy = false;
|
||||
|
||||
/** @type {number | null} Keyboard shortcut binding ID */
|
||||
_shortcutId = null;
|
||||
|
||||
enable() {
|
||||
this._shuttingDown = false;
|
||||
this._daemonRestartAttempts = 0;
|
||||
|
||||
this._settings = this.getSettings();
|
||||
this._excludedApps = this._settings.get_strv('excluded-apps');
|
||||
this._settings.connect('changed::excluded-apps', () => {
|
||||
this._excludedApps = this._settings.get_strv('excluded-apps');
|
||||
});
|
||||
|
||||
this._readSizeLimits();
|
||||
this._configChangedIds = [
|
||||
this._settings.connect('changed::max-history', () => { this._readSizeLimits(); this._pushConfig(); }),
|
||||
this._settings.connect('changed::max-text-mb', () => { this._readSizeLimits(); this._pushConfig(); }),
|
||||
this._settings.connect('changed::max-image-mb', () => { this._readSizeLimits(); this._pushConfig(); }),
|
||||
];
|
||||
|
||||
// 1. Top-bar indicator icon.
|
||||
this._addIndicator();
|
||||
|
||||
// 2. Spawn the Rust daemon.
|
||||
this._spawnDaemon();
|
||||
|
||||
// 3. Connect D-Bus proxy (async - doesn't block if daemon isn't ready yet).
|
||||
this._connectProxy();
|
||||
|
||||
// 4. Track focused window (lightweight - no clipboard I/O).
|
||||
this._connectFocusTracking();
|
||||
|
||||
// 5. Monitor clipboard via Meta.Selection (GNOME-native, no Wayland protocol needed).
|
||||
this._connectClipboardMonitor();
|
||||
|
||||
// 6. Register keyboard shortcut.
|
||||
this._registerShortcut();
|
||||
}
|
||||
|
||||
disable() {
|
||||
// Signal the watchdog NOT to respawn on exit.
|
||||
this._shuttingDown = true;
|
||||
if (this._daemonRestartTimerId !== null) {
|
||||
GLib.Source.remove(this._daemonRestartTimerId);
|
||||
this._daemonRestartTimerId = null;
|
||||
}
|
||||
// Clean up in reverse order.
|
||||
this._unregisterShortcut();
|
||||
this._disconnectClipboardMonitor();
|
||||
this._disconnectFocusTracking();
|
||||
this._disconnectSignals();
|
||||
if (this._proxyOwnerId && this._proxy) {
|
||||
this._proxy.disconnect(this._proxyOwnerId);
|
||||
this._proxyOwnerId = 0;
|
||||
}
|
||||
if (this._configChangedIds && this._settings) {
|
||||
for (const id of this._configChangedIds) this._settings.disconnect(id);
|
||||
this._configChangedIds = null;
|
||||
}
|
||||
this._panel?.destroy();
|
||||
this._panel = null;
|
||||
this._indicator?.destroy();
|
||||
this._indicator = null;
|
||||
this._stopDaemon();
|
||||
this._proxy = null;
|
||||
this._settings = null;
|
||||
}
|
||||
|
||||
|
||||
_addIndicator() {
|
||||
// dontCreateMenu = true so PanelMenu.Button doesn't open an empty popup.
|
||||
this._indicator = new PanelMenu.Button(0.0, 'Strata', true);
|
||||
const icon = new St.Icon({
|
||||
icon_name: 'edit-paste-symbolic',
|
||||
style_class: 'system-status-icon',
|
||||
});
|
||||
this._indicator.add_child(icon);
|
||||
this._indicator.connect('button-press-event', () => {
|
||||
this._panel?.toggle();
|
||||
return false; // EVENT_PROPAGATE
|
||||
});
|
||||
Main.panel.addToStatusArea('strata', this._indicator);
|
||||
}
|
||||
|
||||
|
||||
_connectClipboardMonitor() {
|
||||
const selection = global.display.get_selection();
|
||||
this._selectionChangedId = selection.connect('owner-changed', (_sel, type) => {
|
||||
if (type !== Meta.SelectionType.SELECTION_CLIPBOARD) return;
|
||||
this._scheduleClipboardRead();
|
||||
});
|
||||
}
|
||||
|
||||
_disconnectClipboardMonitor() {
|
||||
// Cancel any pending debounce timer and reset the in-flight guard so
|
||||
// the next enable() cycle starts clean.
|
||||
if (this._clipboardDebounceId !== null) {
|
||||
GLib.Source.remove(this._clipboardDebounceId);
|
||||
this._clipboardDebounceId = null;
|
||||
}
|
||||
this._clipboardTransferPending = false;
|
||||
if (this._selectionChangedId !== null) {
|
||||
global.display.get_selection().disconnect(this._selectionChangedId);
|
||||
this._selectionChangedId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Debounce entry point - coalesces rapid clipboard changes (e.g. from
|
||||
* apps that write clipboard multiple times per operation). */
|
||||
_scheduleClipboardRead() {
|
||||
if (this._clipboardDebounceId !== null) {
|
||||
GLib.Source.remove(this._clipboardDebounceId);
|
||||
this._clipboardDebounceId = null;
|
||||
}
|
||||
this._clipboardDebounceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 50, () => {
|
||||
this._clipboardDebounceId = null;
|
||||
this._readClipboard();
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
|
||||
/** Single in-flight transfer at a time - prevents queuing multiple concurrent
|
||||
* transfer_async + base64_encode operations that would stall the main thread. */
|
||||
_readClipboard() {
|
||||
if (this._clipboardTransferPending) return;
|
||||
const selection = global.display.get_selection();
|
||||
const mimes = selection.get_mimetypes(Meta.SelectionType.SELECTION_CLIPBOARD);
|
||||
// Password managers (KeePassXC, KeePass, some Bitwarden builds) mark
|
||||
// copied secrets with this hint mime. Honoring it lets users keep
|
||||
// their passwords out of clipboard history.
|
||||
if (mimes.includes('x-kde-passwordManagerHint')) return;
|
||||
const mime = this._pickMime(mimes);
|
||||
if (!mime) return;
|
||||
|
||||
this._clipboardTransferPending = true;
|
||||
const outputStream = Gio.MemoryOutputStream.new_resizable();
|
||||
selection.transfer_async(
|
||||
Meta.SelectionType.SELECTION_CLIPBOARD,
|
||||
mime,
|
||||
-1,
|
||||
outputStream,
|
||||
null,
|
||||
(_obj, result) => {
|
||||
this._clipboardTransferPending = false;
|
||||
try {
|
||||
selection.transfer_finish(result);
|
||||
outputStream.close(null);
|
||||
const bytes = outputStream.steal_as_bytes();
|
||||
const size = bytes.get_size();
|
||||
if (size === 0) return;
|
||||
const MAX_TEXT = this._maxTextBytes;
|
||||
const MAX_IMAGE = this._maxImageBytes;
|
||||
if (size > (mime.startsWith('image/') ? MAX_IMAGE : MAX_TEXT)) return;
|
||||
// Send raw bytes as a D-Bus `ay` so we avoid blocking
|
||||
// synchronous base64 work on the GJS main thread.
|
||||
this._proxy?.SubmitItemRemote(mime, bytes.get_data(), () => {});
|
||||
} catch (e) {
|
||||
console.error('[Strata] Clipboard read error:', e.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Pick the best MIME type to store from the offered list (mirrors Rust pick_mime). */
|
||||
_pickMime(mimes) {
|
||||
const PREFERRED = [
|
||||
// Raster images (size-capped at MAX_IMAGE).
|
||||
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
|
||||
'image/bmp', 'image/tiff', 'image/avif', 'image/x-icon',
|
||||
'image/svg+xml',
|
||||
// Plain text (UTF-8 preferred, then locale, then X11 legacy aliases).
|
||||
// Ranked above rich-text so editors that expose both text/plain and
|
||||
// text/html give us raw source, not styled markup.
|
||||
'text/plain;charset=utf-8', 'UTF8_STRING',
|
||||
'text/plain', 'STRING', 'TEXT',
|
||||
// Rich text -- only reached when no plain-text offer is available.
|
||||
'text/html',
|
||||
'text/rtf', 'application/rtf',
|
||||
'text/markdown',
|
||||
// File-manager copy/cut payloads (URI list, not bytes).
|
||||
'x-special/gnome-copied-files',
|
||||
'x-special/nautilus-clipboard',
|
||||
'application/x-kde-cutselection',
|
||||
'text/uri-list',
|
||||
];
|
||||
for (const want of PREFERRED)
|
||||
if (mimes.includes(want)) return want;
|
||||
// Allowlist only: see comment in pick_mime (daemon). Reading unknown
|
||||
// mime types could pull a 1 GB blob into Shell memory before we can
|
||||
// size-check it.
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
_spawnDaemon() {
|
||||
if (this._shuttingDown) return;
|
||||
|
||||
// Check if a daemon is already running (e.g. via systemd user service).
|
||||
// If the D-Bus name is already owned we must not spawn a second instance.
|
||||
Gio.DBus.session.call(
|
||||
'org.freedesktop.DBus', '/org/freedesktop/DBus',
|
||||
'org.freedesktop.DBus', 'GetNameOwner',
|
||||
new GLib.Variant('(s)', [BUS_NAME]),
|
||||
null, Gio.DBusCallFlags.NONE, 2000, null,
|
||||
(_conn, result) => {
|
||||
try {
|
||||
_conn.call_finish(result);
|
||||
// Name already owned - daemon managed externally (systemd etc).
|
||||
console.log('[Strata] daemon already running, skipping spawn');
|
||||
} catch (_) {
|
||||
// Name not owned - spawn it ourselves.
|
||||
this._doSpawnDaemon();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_doSpawnDaemon() {
|
||||
if (this._shuttingDown) return;
|
||||
const daemonPath = GLib.find_program_in_path('strata-daemon');
|
||||
if (!daemonPath) {
|
||||
console.error(
|
||||
'[Strata] strata-daemon not found in PATH. ' +
|
||||
'Install the strata-daemon package or place the binary in your PATH.'
|
||||
);
|
||||
this._notifyDaemonMissing();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this._daemon = new Gio.Subprocess({
|
||||
argv: [daemonPath],
|
||||
flags: Gio.SubprocessFlags.NONE,
|
||||
});
|
||||
this._daemon.init(null);
|
||||
this._daemonSpawnTime = GLib.get_monotonic_time() / 1000; // ms
|
||||
this._daemon.wait_async(null, () => this._onDaemonExited());
|
||||
} catch (e) {
|
||||
console.error('[Strata] Failed to spawn daemon:', e);
|
||||
this._scheduleDaemonRestart();
|
||||
}
|
||||
}
|
||||
|
||||
_notifyDaemonMissing() {
|
||||
try {
|
||||
const source = new imports.ui.messageTray.Source({
|
||||
title: 'Strata',
|
||||
icon: new St.Icon({ icon_name: 'edit-paste-symbolic' }),
|
||||
});
|
||||
Main.messageTray.add(source);
|
||||
const notification = new imports.ui.messageTray.Notification({
|
||||
source,
|
||||
title: 'Strata: daemon not found',
|
||||
body: 'Install the strata-daemon package to enable clipboard history.',
|
||||
urgency: imports.ui.messageTray.Urgency.HIGH,
|
||||
});
|
||||
source.addNotification(notification);
|
||||
} catch (_) {
|
||||
// Message tray may not be available (e.g. during early startup) - already logged above.
|
||||
}
|
||||
}
|
||||
|
||||
_onDaemonExited() {
|
||||
if (!this._daemon) return; // shutdown initiated by _stopDaemon
|
||||
const exit = this._daemon.get_exit_status();
|
||||
const lifetimeMs = (GLib.get_monotonic_time() / 1000) - this._daemonSpawnTime;
|
||||
this._daemon = null;
|
||||
|
||||
if (this._shuttingDown) {
|
||||
console.log(`[Strata] daemon exited cleanly during shutdown (status=${exit})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset attempt counter if the daemon ran long enough to be considered healthy.
|
||||
if (lifetimeMs >= 5000) {
|
||||
this._daemonRestartAttempts = 0;
|
||||
}
|
||||
this._daemonRestartAttempts++;
|
||||
console.error(
|
||||
`[Strata] daemon exited with status ${exit} after ${Math.round(lifetimeMs)}ms ` +
|
||||
`(restart attempt ${this._daemonRestartAttempts})`
|
||||
);
|
||||
|
||||
if (this._daemonRestartAttempts > 5) {
|
||||
console.error(
|
||||
'[Strata] Daemon crashed 5 times in rapid succession - giving up. ' +
|
||||
'Disable and re-enable the extension to retry.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._scheduleDaemonRestart();
|
||||
}
|
||||
|
||||
_scheduleDaemonRestart() {
|
||||
if (this._shuttingDown) return;
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
|
||||
const backoffMs = 1000 * Math.pow(2, Math.max(0, this._daemonRestartAttempts - 1));
|
||||
console.log(`[Strata] respawning daemon in ${backoffMs}ms`);
|
||||
this._daemonRestartTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, backoffMs, () => {
|
||||
this._daemonRestartTimerId = null;
|
||||
this._spawnDaemon();
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
|
||||
_stopDaemon() {
|
||||
if (!this._daemon) return;
|
||||
// Capture reference immediately so the timer doesn't kill a newly-spawned daemon.
|
||||
const daemonToStop = this._daemon;
|
||||
this._daemon = null;
|
||||
try {
|
||||
// Graceful shutdown via D-Bus first.
|
||||
this._proxy?.ShutdownRemote(() => {});
|
||||
} catch (_) {}
|
||||
// Give it 1.5s then force-terminate.
|
||||
GLib.timeout_add(GLib.PRIORITY_LOW, 1500, () => {
|
||||
try { daemonToStop.send_signal(15); } catch (_) {} // SIGTERM
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
_connectProxy() {
|
||||
try {
|
||||
this._proxy = new StrataProxy(
|
||||
Gio.DBus.session,
|
||||
BUS_NAME,
|
||||
OBJECT_PATH,
|
||||
(proxy, error) => {
|
||||
if (error) {
|
||||
console.error('[Strata] D-Bus proxy error:', error);
|
||||
return;
|
||||
}
|
||||
this._connectSignals();
|
||||
this._panel = new StrataPanel(proxy, this._settings, this._indicator);
|
||||
// Push config now if the daemon is already up, and
|
||||
// again on every owner transition so a respawned
|
||||
// daemon picks up the latest values.
|
||||
this._pushConfig();
|
||||
this._proxyOwnerId = proxy.connect('notify::g-name-owner',
|
||||
() => { if (proxy.g_name_owner) this._pushConfig(); });
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[Strata] Failed to create D-Bus proxy:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read size limits from GSettings into instance fields (bytes). */
|
||||
_readSizeLimits() {
|
||||
this._maxHistory = this._settings.get_int('max-history');
|
||||
this._maxTextBytes = this._settings.get_int('max-text-mb') * 1024 * 1024;
|
||||
this._maxImageBytes = this._settings.get_int('max-image-mb') * 1024 * 1024;
|
||||
}
|
||||
|
||||
/** Push runtime limits to the daemon. Safe to call before the proxy
|
||||
* is ready or after the daemon has gone away. */
|
||||
_pushConfig() {
|
||||
this._proxy?.SetConfigRemote(
|
||||
this._maxHistory,
|
||||
this._maxTextBytes,
|
||||
this._maxImageBytes,
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
|
||||
_connectSignals() {
|
||||
this._itemAddedId = Gio.DBus.session.signal_subscribe(
|
||||
BUS_NAME,
|
||||
'org.gnome.Strata.Manager',
|
||||
'ItemAdded',
|
||||
OBJECT_PATH,
|
||||
null,
|
||||
Gio.DBusSignalFlags.NONE,
|
||||
this._onItemAdded.bind(this)
|
||||
);
|
||||
|
||||
this._itemDeletedId = Gio.DBus.session.signal_subscribe(
|
||||
BUS_NAME,
|
||||
'org.gnome.Strata.Manager',
|
||||
'ItemDeleted',
|
||||
OBJECT_PATH,
|
||||
null,
|
||||
Gio.DBusSignalFlags.NONE,
|
||||
(_conn, _sender, _path, _iface, _signal, params) => {
|
||||
const [id] = params.deepUnpack();
|
||||
// Best-effort: unlink the on-disk thumbnail file (if any).
|
||||
// GLib.unlink returns -1 if file doesn't exist; we ignore that.
|
||||
try {
|
||||
const cachePath =
|
||||
`${GLib.get_user_cache_dir()}/strata/thumbnails/${id}.png`;
|
||||
GLib.unlink(cachePath);
|
||||
} catch (_) { /* not all items have thumbnails - fine */ }
|
||||
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
|
||||
this._panel?.removeItem(id);
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
this._historyClearedId = Gio.DBus.session.signal_subscribe(
|
||||
BUS_NAME,
|
||||
'org.gnome.Strata.Manager',
|
||||
'HistoryCleared',
|
||||
OBJECT_PATH,
|
||||
null,
|
||||
Gio.DBusSignalFlags.NONE,
|
||||
() => {
|
||||
// Wipe all on-disk thumbnails when daemon clears history.
|
||||
try {
|
||||
const dir = `${GLib.get_user_cache_dir()}/strata/thumbnails`;
|
||||
const d = Gio.File.new_for_path(dir);
|
||||
if (d.query_exists(null)) {
|
||||
const en = d.enumerate_children(
|
||||
'standard::name', Gio.FileQueryInfoFlags.NONE, null);
|
||||
let info;
|
||||
while ((info = en.next_file(null))) {
|
||||
try { d.get_child(info.get_name()).delete(null); } catch (_) {}
|
||||
}
|
||||
en.close(null);
|
||||
}
|
||||
} catch (e) { console.error('[Strata] cache clear failed:', e); }
|
||||
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
|
||||
this._panel?.clearItems();
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_disconnectSignals() {
|
||||
if (this._itemAddedId !== null) {
|
||||
Gio.DBus.session.signal_unsubscribe(this._itemAddedId);
|
||||
this._itemAddedId = null;
|
||||
}
|
||||
if (this._itemDeletedId !== null) {
|
||||
Gio.DBus.session.signal_unsubscribe(this._itemDeletedId);
|
||||
this._itemDeletedId = null;
|
||||
}
|
||||
if (this._historyClearedId !== null) {
|
||||
Gio.DBus.session.signal_unsubscribe(this._historyClearedId);
|
||||
this._historyClearedId = null;
|
||||
}
|
||||
if (this._pendingSignalId !== null) {
|
||||
GLib.Source.remove(this._pendingSignalId);
|
||||
this._pendingSignalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_onItemAdded(_conn, _sender, _path, _iface, _signal, params) {
|
||||
// Debounce: if the daemon emits a burst, coalesce into one update.
|
||||
if (this._pendingSignalId !== null) {
|
||||
GLib.Source.remove(this._pendingSignalId);
|
||||
this._pendingSignalId = null;
|
||||
}
|
||||
this._pendingSignalId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 50, () => {
|
||||
this._pendingSignalId = null;
|
||||
this._processItemAdded(params).catch(e => console.error('[Strata] ItemAdded error:', e));
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
|
||||
async _processItemAdded(params) {
|
||||
if (this.#busy) return;
|
||||
this.#busy = true;
|
||||
try {
|
||||
const [id, mimeType, preview] = params.deepUnpack();
|
||||
|
||||
// Exclusion check - no clipboard I/O, just string comparison.
|
||||
if (this._isExcluded(this._currentFocusedApp)) {
|
||||
try {
|
||||
await this._proxy.DeleteItemAsync(id);
|
||||
} catch (e) {
|
||||
// ignore - item may already be gone
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Defer UI mutation to after the current frame renders.
|
||||
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
|
||||
this._panel?.prependItem(id, mimeType, preview);
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
} finally {
|
||||
this.#busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
_isExcluded(appClass) {
|
||||
if (!appClass) return false;
|
||||
return this._excludedApps.some(ex => appClass.includes(ex.toLowerCase()));
|
||||
}
|
||||
|
||||
|
||||
_connectFocusTracking() {
|
||||
this._focusSignalId = global.display.connect('notify::focus-window', () => {
|
||||
const win = global.display.focus_window;
|
||||
this._currentFocusedApp = (win?.get_wm_class() ?? '').toLowerCase();
|
||||
// Inform the daemon (best-effort, ignore failures).
|
||||
this._proxy?.SetFocusedAppRemote(this._currentFocusedApp, () => {});
|
||||
});
|
||||
}
|
||||
|
||||
_disconnectFocusTracking() {
|
||||
if (this._focusSignalId !== null) {
|
||||
global.display.disconnect(this._focusSignalId);
|
||||
this._focusSignalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_registerShortcut() {
|
||||
Main.wm.addKeybinding(
|
||||
'keyboard-shortcut',
|
||||
this._settings,
|
||||
Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
|
||||
Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW,
|
||||
() => this._panel?.toggle()
|
||||
);
|
||||
}
|
||||
|
||||
_unregisterShortcut() {
|
||||
Main.wm.removeKeybinding('keyboard-shortcut');
|
||||
}
|
||||
}
|
||||
9
strata@edu4rdshl.dev/metadata.json
Normal file
9
strata@edu4rdshl.dev/metadata.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "Strata",
|
||||
"description": "A fast, stutter-free clipboard manager. All I/O runs in a Rust daemon - GNOME Shell is never blocked.",
|
||||
"uuid": "strata@edu4rdshl.dev",
|
||||
"version": 2,
|
||||
"shell-version": ["45", "46", "47", "48", "49", "50"],
|
||||
"settings-schema": "org.gnome.shell.extensions.strata",
|
||||
"url": "https://github.com/Edu4rdSHL/Strata"
|
||||
}
|
||||
347
strata@edu4rdshl.dev/prefs.js
Normal file
347
strata@edu4rdshl.dev/prefs.js
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
/**
|
||||
* prefs.js - Strata preferences window (GNOME 45+ / Adw).
|
||||
*
|
||||
* Pages:
|
||||
* General: max-history SpinRow, keyboard-shortcut ShortcutRow
|
||||
* Privacy: excluded-apps ExpanderRow + StringList (one entry per line)
|
||||
*/
|
||||
|
||||
import Adw from 'gi://Adw';
|
||||
import Gtk from 'gi://Gtk';
|
||||
import Gdk from 'gi://Gdk';
|
||||
import GLib from 'gi://GLib';
|
||||
import Gio from 'gi://Gio';
|
||||
|
||||
import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
|
||||
|
||||
export default class StrataPreferences extends ExtensionPreferences {
|
||||
fillPreferencesWindow(window) {
|
||||
const settings = this.getSettings();
|
||||
|
||||
window.add(this._buildGeneralPage(settings));
|
||||
window.add(this._buildPrivacyPage(settings));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// General page
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_buildGeneralPage(settings) {
|
||||
const page = new Adw.PreferencesPage({
|
||||
title: 'General',
|
||||
icon_name: 'preferences-system-symbolic',
|
||||
});
|
||||
|
||||
// ── History ──────────────────────────────────────────────────────────
|
||||
const historyGroup = new Adw.PreferencesGroup({ title: 'History' });
|
||||
|
||||
const maxHistoryRow = new Adw.SpinRow({
|
||||
title: 'Maximum items',
|
||||
subtitle: 'All items are searchable and reachable; older are deleted past this limit',
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 50,
|
||||
upper: 2000,
|
||||
step_increment: 10,
|
||||
page_increment: 50,
|
||||
}),
|
||||
});
|
||||
settings.bind('max-history', maxHistoryRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
historyGroup.add(maxHistoryRow);
|
||||
|
||||
const pageSizeRow = new Adw.SpinRow({
|
||||
title: 'Items per page',
|
||||
subtitle: 'Rows fetched on open and on each scroll-to-bottom',
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 20,
|
||||
upper: 200,
|
||||
step_increment: 10,
|
||||
page_increment: 50,
|
||||
}),
|
||||
});
|
||||
settings.bind('page-size', pageSizeRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
historyGroup.add(pageSizeRow);
|
||||
|
||||
const maxTextRow = new Adw.SpinRow({
|
||||
title: 'Maximum text size (MB)',
|
||||
subtitle: 'Text payloads larger than this are not stored',
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 1,
|
||||
upper: 100,
|
||||
step_increment: 1,
|
||||
page_increment: 5,
|
||||
}),
|
||||
});
|
||||
settings.bind('max-text-mb', maxTextRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
historyGroup.add(maxTextRow);
|
||||
|
||||
const maxImageRow = new Adw.SpinRow({
|
||||
title: 'Maximum image size (MB)',
|
||||
subtitle: 'Image payloads larger than this are not stored',
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 1,
|
||||
upper: 100,
|
||||
step_increment: 1,
|
||||
page_increment: 5,
|
||||
}),
|
||||
});
|
||||
settings.bind('max-image-mb', maxImageRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
historyGroup.add(maxImageRow);
|
||||
|
||||
page.add(historyGroup);
|
||||
|
||||
// ── Appearance ───────────────────────────────────────────────────────
|
||||
const appearanceGroup = new Adw.PreferencesGroup({ title: 'Appearance' });
|
||||
|
||||
const panelWidthRow = new Adw.SpinRow({
|
||||
title: 'Panel width',
|
||||
subtitle: 'Width of the clipboard panel in pixels',
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 200,
|
||||
upper: 1200,
|
||||
step_increment: 10,
|
||||
page_increment: 50,
|
||||
}),
|
||||
});
|
||||
settings.bind('panel-width', panelWidthRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
appearanceGroup.add(panelWidthRow);
|
||||
|
||||
const panelMaxHeightRow = new Adw.SpinRow({
|
||||
title: 'Panel max height',
|
||||
subtitle: 'Maximum height before the list scrolls',
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 200,
|
||||
upper: 1200,
|
||||
step_increment: 10,
|
||||
page_increment: 50,
|
||||
}),
|
||||
});
|
||||
settings.bind('panel-max-height', panelMaxHeightRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
appearanceGroup.add(panelMaxHeightRow);
|
||||
|
||||
const moveToTopRow = new Adw.SwitchRow({
|
||||
title: 'Move activated item to top',
|
||||
subtitle: 'Selecting an item moves it to the top of the list',
|
||||
});
|
||||
settings.bind('move-activated-to-top', moveToTopRow, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||
appearanceGroup.add(moveToTopRow);
|
||||
|
||||
const positions = [
|
||||
{ id: 'top-center', label: 'Top center' },
|
||||
{ id: 'top-left', label: 'Top left' },
|
||||
{ id: 'top-right', label: 'Top right' },
|
||||
{ id: 'center', label: 'Center' },
|
||||
{ id: 'bottom-center', label: 'Bottom center' },
|
||||
{ id: 'bottom-left', label: 'Bottom left' },
|
||||
{ id: 'bottom-right', label: 'Bottom right' },
|
||||
];
|
||||
|
||||
const positionRow = new Adw.ComboRow({
|
||||
title: 'Panel position',
|
||||
subtitle: 'Where the clipboard panel appears on screen',
|
||||
model: Gtk.StringList.new(positions.map(p => p.label)),
|
||||
});
|
||||
|
||||
const currentPos = settings.get_string('panel-position');
|
||||
const currentIdx = positions.findIndex(p => p.id === currentPos);
|
||||
positionRow.selected = currentIdx >= 0 ? currentIdx : 0;
|
||||
|
||||
positionRow.connect('notify::selected', () => {
|
||||
settings.set_string('panel-position', positions[positionRow.selected].id);
|
||||
});
|
||||
settings.connect('changed::panel-position', () => {
|
||||
const idx = positions.findIndex(p => p.id === settings.get_string('panel-position'));
|
||||
if (idx >= 0 && positionRow.selected !== idx)
|
||||
positionRow.selected = idx;
|
||||
});
|
||||
appearanceGroup.add(positionRow);
|
||||
|
||||
page.add(appearanceGroup);
|
||||
|
||||
// ── Keyboard ─────────────────────────────────────────────────────────
|
||||
const kbGroup = new Adw.PreferencesGroup({ title: 'Keyboard' });
|
||||
|
||||
const shortcutRow = new Adw.ActionRow({
|
||||
title: 'Open Strata',
|
||||
subtitle: 'Click to change the keyboard shortcut',
|
||||
activatable: true,
|
||||
});
|
||||
const shortcutLabel = new Gtk.ShortcutLabel({
|
||||
valign: Gtk.Align.CENTER,
|
||||
disabled_text: 'Disabled',
|
||||
});
|
||||
const updateShortcutLabel = () => {
|
||||
const shortcuts = settings.get_strv('keyboard-shortcut');
|
||||
shortcutLabel.accelerator = shortcuts[0] ?? '';
|
||||
};
|
||||
updateShortcutLabel();
|
||||
settings.connect('changed::keyboard-shortcut', updateShortcutLabel);
|
||||
shortcutRow.add_suffix(shortcutLabel);
|
||||
shortcutRow.connect('activated', () => {
|
||||
this._showShortcutDialog(shortcutRow.get_root(), settings);
|
||||
});
|
||||
kbGroup.add(shortcutRow);
|
||||
page.add(kbGroup);
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Privacy page
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_buildPrivacyPage(settings) {
|
||||
const page = new Adw.PreferencesPage({
|
||||
title: 'Privacy',
|
||||
icon_name: 'security-high-symbolic',
|
||||
});
|
||||
|
||||
const group = new Adw.PreferencesGroup({
|
||||
title: 'App Exclusions',
|
||||
description: 'Items copied while these apps have focus will not be stored in history. Enter a partial app name (case-insensitive).',
|
||||
});
|
||||
|
||||
// We use a StringList model bound to excluded-apps.
|
||||
const model = new Gtk.StringList();
|
||||
const currentApps = settings.get_strv('excluded-apps');
|
||||
for (const app of currentApps)
|
||||
model.append(app);
|
||||
|
||||
/** Sync the StringList back to GSettings. */
|
||||
const saveModel = () => {
|
||||
const apps = [];
|
||||
for (let i = 0; i < model.get_n_items(); i++) {
|
||||
const val = model.get_string(i);
|
||||
if (val?.trim()) apps.push(val.trim());
|
||||
}
|
||||
settings.set_strv('excluded-apps', apps);
|
||||
};
|
||||
|
||||
// Each item in the list: an EditableLabel + Remove button.
|
||||
const listBox = new Gtk.ListBox({
|
||||
selection_mode: Gtk.SelectionMode.NONE,
|
||||
css_classes: ['boxed-list'],
|
||||
});
|
||||
|
||||
const rebuildList = () => {
|
||||
let child = listBox.get_first_child();
|
||||
while (child) {
|
||||
const next = child.get_next_sibling();
|
||||
listBox.remove(child);
|
||||
child = next;
|
||||
}
|
||||
for (let i = 0; i < model.get_n_items(); i++) {
|
||||
const idx = i;
|
||||
const row = new Adw.ActionRow({ activatable: false });
|
||||
const label = new Gtk.EditableLabel({
|
||||
text: model.get_string(i),
|
||||
valign: Gtk.Align.CENTER,
|
||||
hexpand: true,
|
||||
});
|
||||
label.connect('changed', () => {
|
||||
model.splice(idx, 1, [label.text]);
|
||||
saveModel();
|
||||
});
|
||||
const removeBtn = new Gtk.Button({
|
||||
icon_name: 'list-remove-symbolic',
|
||||
valign: Gtk.Align.CENTER,
|
||||
css_classes: ['flat', 'destructive-action'],
|
||||
tooltip_text: 'Remove',
|
||||
});
|
||||
removeBtn.connect('clicked', () => {
|
||||
model.remove(idx);
|
||||
saveModel();
|
||||
rebuildList();
|
||||
});
|
||||
row.add_suffix(label);
|
||||
row.add_suffix(removeBtn);
|
||||
listBox.append(row);
|
||||
}
|
||||
|
||||
// Add-new row.
|
||||
const addRow = new Adw.ActionRow({ activatable: false });
|
||||
const addEntry = new Gtk.Entry({
|
||||
placeholder_text: 'App name…',
|
||||
valign: Gtk.Align.CENTER,
|
||||
hexpand: true,
|
||||
});
|
||||
const addBtn = new Gtk.Button({
|
||||
icon_name: 'list-add-symbolic',
|
||||
valign: Gtk.Align.CENTER,
|
||||
css_classes: ['flat'],
|
||||
tooltip_text: 'Add',
|
||||
});
|
||||
const doAdd = () => {
|
||||
const val = addEntry.text.trim();
|
||||
if (val) {
|
||||
model.append(val);
|
||||
saveModel();
|
||||
rebuildList();
|
||||
}
|
||||
};
|
||||
addBtn.connect('clicked', doAdd);
|
||||
addEntry.connect('activate', doAdd);
|
||||
addRow.add_suffix(addEntry);
|
||||
addRow.add_suffix(addBtn);
|
||||
listBox.append(addRow);
|
||||
};
|
||||
|
||||
rebuildList();
|
||||
group.add(listBox);
|
||||
page.add(group);
|
||||
return page;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Keyboard shortcut dialog
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_showShortcutDialog(parent, settings) {
|
||||
const dialog = new Adw.MessageDialog({
|
||||
heading: 'Set Keyboard Shortcut',
|
||||
body: 'Press the desired key combination, or Backspace to clear.',
|
||||
transient_for: parent,
|
||||
modal: true,
|
||||
});
|
||||
dialog.add_response('cancel', 'Cancel');
|
||||
dialog.add_response('clear', 'Clear');
|
||||
|
||||
const label = new Gtk.ShortcutLabel({
|
||||
accelerator: settings.get_strv('keyboard-shortcut')[0] ?? '',
|
||||
disabled_text: '(none)',
|
||||
margin_top: 12,
|
||||
margin_bottom: 12,
|
||||
halign: Gtk.Align.CENTER,
|
||||
});
|
||||
dialog.set_extra_child(label);
|
||||
|
||||
const controller = new Gtk.EventControllerKey();
|
||||
controller.connect('key-pressed', (_ctrl, keyval, keycode, state) => {
|
||||
const mods = state & Gtk.accelerator_get_default_mod_mask();
|
||||
if (keyval === Gdk.KEY_BackSpace) {
|
||||
label.accelerator = '';
|
||||
return true;
|
||||
}
|
||||
if (keyval === Gdk.KEY_Escape) {
|
||||
dialog.close();
|
||||
return true;
|
||||
}
|
||||
if (Gtk.accelerator_valid(keyval, mods)) {
|
||||
label.accelerator = Gtk.accelerator_name(keyval, mods);
|
||||
settings.set_strv('keyboard-shortcut',
|
||||
label.accelerator ? [label.accelerator] : []);
|
||||
dialog.close();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
dialog.add_controller(controller);
|
||||
|
||||
dialog.connect('response', (_d, response) => {
|
||||
if (response === 'clear') {
|
||||
settings.set_strv('keyboard-shortcut', []);
|
||||
}
|
||||
dialog.destroy();
|
||||
});
|
||||
|
||||
dialog.present();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<schemalist>
|
||||
<schema id="org.gnome.shell.extensions.strata"
|
||||
path="/org/gnome/shell/extensions/strata/">
|
||||
|
||||
<key name="max-history" type="i">
|
||||
<default>200</default>
|
||||
<range min="50" max="2000"/>
|
||||
<summary>Maximum number of clipboard history items to keep</summary>
|
||||
<description>All stored items are searchable (text only) and reachable via scroll. Higher values use more disk space (~30KB per image, ~1KB per text item).</description>
|
||||
</key>
|
||||
|
||||
<key name="max-text-mb" type="i">
|
||||
<default>1</default>
|
||||
<range min="1" max="100"/>
|
||||
<summary>Maximum text clipboard item size (MB)</summary>
|
||||
<description>Text payloads larger than this are not stored.</description>
|
||||
</key>
|
||||
|
||||
<key name="max-image-mb" type="i">
|
||||
<default>5</default>
|
||||
<range min="1" max="100"/>
|
||||
<summary>Maximum image clipboard item size (MB)</summary>
|
||||
<description>Image payloads larger than this are not stored.</description>
|
||||
</key>
|
||||
|
||||
<key name="page-size" type="i">
|
||||
<default>50</default>
|
||||
<range min="20" max="200"/>
|
||||
<summary>Number of items to fetch per history page</summary>
|
||||
<description>The panel loads this many rows on open and again each time the scroll reaches the bottom.</description>
|
||||
</key>
|
||||
|
||||
<key name="keyboard-shortcut" type="as">
|
||||
<default><![CDATA[['<Super><Shift>v']]]></default>
|
||||
<summary>Keyboard shortcut to open the clipboard panel</summary>
|
||||
</key>
|
||||
|
||||
<key name="panel-width" type="i">
|
||||
<default>480</default>
|
||||
<summary>Width of the clipboard panel in pixels</summary>
|
||||
</key>
|
||||
|
||||
<key name="panel-max-height" type="i">
|
||||
<default>600</default>
|
||||
<summary>Maximum height of the clipboard panel in pixels</summary>
|
||||
</key>
|
||||
|
||||
<key name="move-activated-to-top" type="b">
|
||||
<default>true</default>
|
||||
<summary>Move the selected item to the top of the list when activated</summary>
|
||||
<description>When enabled, clicking or pressing Enter on a history item moves it to position 1 in the list.</description>
|
||||
</key>
|
||||
|
||||
<key name="panel-position" type="s">
|
||||
<choices>
|
||||
<choice value="top-center"/>
|
||||
<choice value="top-left"/>
|
||||
<choice value="top-right"/>
|
||||
<choice value="center"/>
|
||||
<choice value="bottom-center"/>
|
||||
<choice value="bottom-left"/>
|
||||
<choice value="bottom-right"/>
|
||||
</choices>
|
||||
<default>'top-center'</default>
|
||||
<summary>Position of the clipboard panel on screen</summary>
|
||||
</key>
|
||||
|
||||
<key name="excluded-apps" type="as">
|
||||
<default><![CDATA[['1password', 'keepassxc', 'keepass', 'gnome-secrets', 'bitwarden', 'dashlane', 'lastpass', 'enpass']]]></default>
|
||||
<summary>App WM class substrings to exclude from clipboard history</summary>
|
||||
<description>Items copied while one of these apps has keyboard focus will not be stored in history. The match is case-insensitive and uses substring matching.</description>
|
||||
</key>
|
||||
|
||||
</schema>
|
||||
</schemalist>
|
||||
205
strata@edu4rdshl.dev/stylesheet.css
Normal file
205
strata@edu4rdshl.dev/stylesheet.css
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
/* Strata clipboard manager stylesheet.
|
||||
*
|
||||
* Performance rule: NO `transition: all`, NO Clutter/CSS animations.
|
||||
* Only background-color transitions on hover (GPU-composited, zero JS).
|
||||
* clip-to-allocation on the scroll view prevents overdraw outside the panel.
|
||||
*/
|
||||
|
||||
/* ── Panel container ────────────────────────────────────────────────────── */
|
||||
.strata-panel {
|
||||
background-color: rgba(30, 30, 30, 0.97);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
.strata-header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.strata-title {
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.strata-clear-btn {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
padding: 3px 10px;
|
||||
font-size: 0.85em;
|
||||
transition: background-color 100ms;
|
||||
}
|
||||
|
||||
.strata-clear-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* ── Search box ─────────────────────────────────────────────────────────── */
|
||||
.strata-search {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 8px;
|
||||
caret-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.strata-search:focus {
|
||||
border-color: rgba(99, 163, 255, 0.6);
|
||||
background-color: rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
|
||||
/* ── Scroll view ────────────────────────────────────────────────────────── */
|
||||
.strata-scroll {
|
||||
clip-to-allocation: true;
|
||||
}
|
||||
|
||||
.strata-item-list {
|
||||
spacing: 2px;
|
||||
}
|
||||
|
||||
/* ── Clipboard item ─────────────────────────────────────────────────────── */
|
||||
.strata-item {
|
||||
border-radius: 8px;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
transition: background-color 100ms;
|
||||
}
|
||||
|
||||
.strata-item:hover,
|
||||
.strata-item-hovered {
|
||||
background-color: rgba(80, 140, 255, 0.18);
|
||||
border-color: rgba(80, 140, 255, 0.45);
|
||||
}
|
||||
|
||||
/* Keyboard-focused item - clearly distinct from hover with accent blue.
|
||||
* Both :focus (CSS pseudo) and .strata-item-focused (JS-added class) are used
|
||||
* for belt-and-suspenders reliability across GNOME Shell versions. */
|
||||
.strata-item:focus,
|
||||
.strata-item-focused {
|
||||
background-color: rgba(80, 140, 255, 0.18);
|
||||
border-color: rgba(80, 140, 255, 0.60);
|
||||
}
|
||||
|
||||
.strata-item:hover:focus,
|
||||
.strata-item-focused.strata-item-hovered {
|
||||
background-color: rgba(80, 140, 255, 0.28);
|
||||
border-color: rgba(80, 140, 255, 0.70);
|
||||
}
|
||||
|
||||
/* Bold the main text label when the item is focused.
|
||||
* Also reinforced via JS key-focus-in signal in clipboardItem.js. */
|
||||
.strata-item:focus .strata-item-text,
|
||||
.strata-item-focused .strata-item-text {
|
||||
font-weight: bold;
|
||||
color: rgba(255, 255, 255, 1.0);
|
||||
}
|
||||
|
||||
/* Active item - the most recently copied entry.
|
||||
* Warm amber accent so it stands out from hover (white) and focus (blue). */
|
||||
.strata-item-active {
|
||||
background-color: rgba(255, 200, 80, 0.14);
|
||||
border-color: rgba(255, 200, 80, 0.45);
|
||||
}
|
||||
|
||||
.strata-item-active .strata-item-text {
|
||||
color: rgba(255, 235, 160, 1.0);
|
||||
}
|
||||
|
||||
.strata-item-active.strata-item-hovered {
|
||||
background-color: rgba(255, 200, 80, 0.22);
|
||||
}
|
||||
|
||||
/* Blue focus wins over amber active when navigating via keyboard. */
|
||||
.strata-item-active.strata-item-focused,
|
||||
.strata-item-active:focus {
|
||||
background-color: rgba(80, 140, 255, 0.22);
|
||||
border-color: rgba(80, 140, 255, 0.60);
|
||||
}
|
||||
|
||||
.strata-item-active.strata-item-focused .strata-item-text,
|
||||
.strata-item-active:focus .strata-item-text {
|
||||
font-weight: bold;
|
||||
color: rgba(255, 255, 255, 1.0);
|
||||
}
|
||||
|
||||
.strata-item:active {
|
||||
background-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.strata-item-row {
|
||||
padding: 8px 10px;
|
||||
spacing: 10px;
|
||||
}
|
||||
|
||||
/* ── Item icon ──────────────────────────────────────────────────────────── */
|
||||
.strata-item-icon {
|
||||
opacity: 0.6;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
/* ── Image thumbnail ────────────────────────────────────────────────────── */
|
||||
.strata-item-thumb {
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* ── Color swatch ───────────────────────────────────────────────────────── */
|
||||
.strata-item-swatch {
|
||||
/* Inline style used for actual color - only shape/border set here. */
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Text content ───────────────────────────────────────────────────────── */
|
||||
.strata-item-content {
|
||||
/* spacing between main and sub label */
|
||||
spacing: 2px;
|
||||
}
|
||||
|
||||
.strata-item-text {
|
||||
color: rgba(255, 255, 255, 0.90);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.strata-item-url {
|
||||
color: rgba(99, 163, 255, 0.9);
|
||||
}
|
||||
|
||||
.strata-item-subtext {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
/* ── Delete button ──────────────────────────────────────────────────────── */
|
||||
.strata-item-delete {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.0);
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
transition: background-color 100ms, color 100ms;
|
||||
}
|
||||
|
||||
/* Only show the delete icon on row hover or keyboard focus. */
|
||||
.strata-item:hover .strata-item-delete,
|
||||
.strata-item:focus .strata-item-delete,
|
||||
.strata-item-focused .strata-item-delete,
|
||||
.strata-item-hovered .strata-item-delete {
|
||||
color: rgba(255, 80, 80, 0.7);
|
||||
}
|
||||
|
||||
.strata-item-delete:hover {
|
||||
background-color: rgba(255, 80, 80, 0.15);
|
||||
color: rgba(255, 80, 80, 1.0);
|
||||
}
|
||||
267
strata@edu4rdshl.dev/ui/clipboardItem.js
Normal file
267
strata@edu4rdshl.dev/ui/clipboardItem.js
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
/* clipboardItem.js - row widget for the clipboard list. */
|
||||
|
||||
import GObject from 'gi://GObject';
|
||||
import GLib from 'gi://GLib';
|
||||
import St from 'gi://St';
|
||||
import Clutter from 'gi://Clutter';
|
||||
import Gio from 'gi://Gio';
|
||||
|
||||
const TEXT_PREVIEW_LEN = 140;
|
||||
const THUMB_SIZE = 48;
|
||||
|
||||
const ICON_BY_MIME = {
|
||||
'text/uri-list': 'emblem-web-symbolic',
|
||||
'text/html': 'text-html-symbolic',
|
||||
'image/': 'image-x-generic-symbolic',
|
||||
};
|
||||
|
||||
function iconForMime(mimeType) {
|
||||
for (const [prefix, icon] of Object.entries(ICON_BY_MIME)) {
|
||||
if (mimeType.startsWith(prefix)) return icon;
|
||||
}
|
||||
return 'edit-copy-symbolic';
|
||||
}
|
||||
|
||||
/** Detect if a string looks like a URL. */
|
||||
function isUrl(text) {
|
||||
return /^https?:\/\/.+/i.test(text.trim());
|
||||
}
|
||||
|
||||
/** Detect if a string looks like a CSS/hex color. */
|
||||
function isColor(text) {
|
||||
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(text.trim());
|
||||
}
|
||||
|
||||
export const ClipboardItem = GObject.registerClass({
|
||||
Signals: {
|
||||
'activate': {},
|
||||
'delete': {},
|
||||
},
|
||||
}, class ClipboardItem extends St.Button {
|
||||
_init(id, mimeType, preview, opts = {}) {
|
||||
super._init({
|
||||
style_class: 'strata-item',
|
||||
x_expand: true,
|
||||
can_focus: true,
|
||||
reactive: true,
|
||||
});
|
||||
|
||||
this._id = id;
|
||||
this._mimeType = mimeType;
|
||||
/** D-Bus proxy used for lazy thumbnail fetch. */
|
||||
this._proxy = opts.proxy ?? null;
|
||||
/** Optional shared LRU cache (Map<id, string filePath>) - populated on first fetch. */
|
||||
this._thumbCache = opts.thumbCache ?? null;
|
||||
/** Stored on the actor so panel.js can filter by it. */
|
||||
this.actor = this;
|
||||
this._strataPreview = mimeType.startsWith('image/') ? '' : preview;
|
||||
|
||||
const row = new St.BoxLayout({
|
||||
style_class: 'strata-item-row',
|
||||
x_expand: true,
|
||||
});
|
||||
|
||||
// Left: icon or image thumbnail
|
||||
row.add_child(this._buildLeading(mimeType, preview));
|
||||
|
||||
// Center: text preview
|
||||
row.add_child(this._buildContent(mimeType, preview));
|
||||
|
||||
// Right: delete button
|
||||
const deleteBtn = new St.Button({
|
||||
style_class: 'strata-item-delete',
|
||||
icon_name: 'edit-delete-symbolic',
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
deleteBtn.connect('clicked', () => {
|
||||
this.emit('delete');
|
||||
return Clutter.EVENT_STOP;
|
||||
});
|
||||
row.add_child(deleteBtn);
|
||||
|
||||
this.set_child(row);
|
||||
this.connect('clicked', () => this.emit('activate'));
|
||||
|
||||
// Make the item text bold when keyboard-focused so the selected item
|
||||
// is immediately obvious during arrow-key navigation.
|
||||
// We add/remove an explicit style class because CSS :focus pseudo-class
|
||||
// can be unreliable in GNOME Shell extensions.
|
||||
this.connect('key-focus-in', () => {
|
||||
this.add_style_class_name('strata-item-focused');
|
||||
if (this._mainLabel)
|
||||
this._mainLabel.style = 'font-weight: bold; color: rgba(255, 255, 255, 1.0);';
|
||||
});
|
||||
this.connect('key-focus-out', () => {
|
||||
this.remove_style_class_name('strata-item-focused');
|
||||
if (this._mainLabel)
|
||||
this._mainLabel.style = null;
|
||||
});
|
||||
}
|
||||
|
||||
_buildLeading(mimeType, preview) {
|
||||
if (mimeType.startsWith('image/')) {
|
||||
return this._buildThumbnail(this._id);
|
||||
}
|
||||
if (isColor(preview)) {
|
||||
return this._buildColorSwatch(preview);
|
||||
}
|
||||
const icon = new St.Icon({
|
||||
icon_name: iconForMime(mimeType),
|
||||
icon_size: 20,
|
||||
style_class: 'strata-item-icon',
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
return icon;
|
||||
}
|
||||
|
||||
/** Build an image thumbnail container.
|
||||
* - If already in shared cache, apply immediately.
|
||||
* - Else fetch via GetThumbnailRemote, write to ~/.cache/strata/thumbnails/{id}.png,
|
||||
* then apply. The decode happens off-thread (CSS background-image loader). */
|
||||
_buildThumbnail(id) {
|
||||
const container = new St.Widget({
|
||||
width: THUMB_SIZE,
|
||||
height: THUMB_SIZE,
|
||||
style_class: 'strata-item-thumb',
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
|
||||
const cacheDir = `${GLib.get_user_cache_dir()}/strata/thumbnails`;
|
||||
const cachePath = `${cacheDir}/${id}.png`;
|
||||
const fileUri = `file://${cachePath}`;
|
||||
|
||||
const applyStyle = () => {
|
||||
try {
|
||||
container.style = `background-image: url("${fileUri}"); background-size: cover; background-repeat: no-repeat;`;
|
||||
} catch (_) { /* container was destroyed mid-flight */ }
|
||||
};
|
||||
|
||||
try {
|
||||
// Fast path: already cached this session.
|
||||
if (this._thumbCache?.has(id)) {
|
||||
applyStyle();
|
||||
return container;
|
||||
}
|
||||
// Second fast path: file exists on disk from a previous session.
|
||||
if (GLib.file_test(cachePath, GLib.FileTest.EXISTS)) {
|
||||
this._thumbCache?.set(id, cachePath);
|
||||
applyStyle();
|
||||
return container;
|
||||
}
|
||||
// Slow path: ask the daemon for the bytes, then write file async.
|
||||
GLib.mkdir_with_parents(cacheDir, 0o755);
|
||||
if (!this._proxy) {
|
||||
this._fallbackIcon(container);
|
||||
return container;
|
||||
}
|
||||
this._proxy.GetThumbnailRemote(id, ([bytes]) => {
|
||||
try {
|
||||
if (!container.get_parent()) return; // destroyed
|
||||
if (!bytes || bytes.length === 0) {
|
||||
this._fallbackIcon(container);
|
||||
return;
|
||||
}
|
||||
const file = Gio.File.new_for_path(cachePath);
|
||||
file.replace_contents_bytes_async(
|
||||
new GLib.Bytes(bytes),
|
||||
null,
|
||||
false,
|
||||
Gio.FileCreateFlags.NONE,
|
||||
null,
|
||||
(_f, result) => {
|
||||
try {
|
||||
_f.replace_contents_finish(result);
|
||||
this._thumbCache?.set(id, cachePath);
|
||||
applyStyle();
|
||||
} catch (e) {
|
||||
console.error('[Strata] Thumbnail write error:', e);
|
||||
this._fallbackIcon(container);
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[Strata] Thumbnail fetch handler error:', e);
|
||||
this._fallbackIcon(container);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[Strata] Thumbnail render error:', e);
|
||||
this._fallbackIcon(container);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
_fallbackIcon(container) {
|
||||
if (container.get_n_children() > 0) return;
|
||||
try {
|
||||
const icon = new St.Icon({
|
||||
icon_name: 'image-x-generic-symbolic',
|
||||
icon_size: 20,
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
x_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
container.add_child(icon);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
_buildColorSwatch(hex) {
|
||||
return new St.Widget({
|
||||
width: 24,
|
||||
height: 24,
|
||||
style: `background-color: ${hex}; border-radius: 4px; border: 1px solid rgba(0,0,0,0.2);`,
|
||||
style_class: 'strata-item-swatch',
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
}
|
||||
|
||||
_buildContent(mimeType, preview) {
|
||||
preview = preview ?? '';
|
||||
const box = new St.BoxLayout({
|
||||
vertical: true,
|
||||
x_expand: true,
|
||||
style_class: 'strata-item-content',
|
||||
});
|
||||
|
||||
let mainText = '';
|
||||
let subText = '';
|
||||
|
||||
if (mimeType.startsWith('image/')) {
|
||||
mainText = mimeType === 'image/png' ? 'PNG image' : 'Image';
|
||||
} else if (isUrl(preview)) {
|
||||
mainText = preview.trim();
|
||||
try {
|
||||
subText = new URL(preview.trim()).hostname;
|
||||
} catch (_) {}
|
||||
} else if (isColor(preview)) {
|
||||
mainText = preview.trim().toUpperCase();
|
||||
subText = 'Color';
|
||||
} else {
|
||||
const trimmed = preview.replace(/\s+/g, ' ').trim();
|
||||
mainText = trimmed.length > TEXT_PREVIEW_LEN
|
||||
? trimmed.slice(0, TEXT_PREVIEW_LEN) + '…'
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
const labelMain = new St.Label({
|
||||
text: mainText || '(empty)',
|
||||
style_class: `strata-item-text${isUrl(preview) ? ' strata-item-url' : ''}`,
|
||||
x_expand: true,
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
labelMain.clutter_text.line_wrap = false;
|
||||
labelMain.clutter_text.ellipsize = 3; // PANGO_ELLIPSIZE_END
|
||||
this._mainLabel = labelMain; // held for key-focus bold styling
|
||||
box.add_child(labelMain);
|
||||
|
||||
if (subText) {
|
||||
const labelSub = new St.Label({
|
||||
text: subText,
|
||||
style_class: 'strata-item-subtext',
|
||||
x_expand: true,
|
||||
});
|
||||
box.add_child(labelSub);
|
||||
}
|
||||
|
||||
return box;
|
||||
}
|
||||
});
|
||||
669
strata@edu4rdshl.dev/ui/panel.js
Normal file
669
strata@edu4rdshl.dev/ui/panel.js
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
/* panel.js - Strata clipboard popup panel. */
|
||||
|
||||
import GLib from 'gi://GLib';
|
||||
import St from 'gi://St';
|
||||
import Clutter from 'gi://Clutter';
|
||||
import Meta from 'gi://Meta';
|
||||
import Shell from 'gi://Shell';
|
||||
|
||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||
import { ClipboardItem } from './clipboardItem.js';
|
||||
|
||||
const SEARCH_LIMIT = 500;
|
||||
const SEARCH_DEBOUNCE_MS = 150;
|
||||
const LOAD_MORE_THRESHOLD = 200;
|
||||
|
||||
export class StrataPanel {
|
||||
constructor(proxy, settings, indicator = null) {
|
||||
this._proxy = proxy;
|
||||
this._settings = settings;
|
||||
this._indicator = indicator; // PanelMenu.Button - used to avoid toggle-reopen race
|
||||
this._pageSize = settings.get_int('page-size');
|
||||
this._pageSizeChangedId = settings.connect('changed::page-size',
|
||||
() => { this._pageSize = settings.get_int('page-size'); });
|
||||
/** @type {{ id: string, mimeType: string, preview: string }[]} */
|
||||
this._items = [];
|
||||
/** @type {Map<string, ClipboardItem>} id → widget */
|
||||
this._widgets = new Map();
|
||||
/** @type {Map<string, string>} id → cache file path (shared across widget lifetimes) */
|
||||
this._thumbCache = new Map();
|
||||
/** Pagination state for the non-search view. */
|
||||
this._loadedOffset = 0; // how many items we've already pulled
|
||||
this._hasMore = true; // false once daemon returns < PAGE_SIZE
|
||||
this._loadingMore = false; // re-entrancy guard for scroll-driven loads
|
||||
/** Search state. */
|
||||
this._searchQuery = ''; // current search string ('' = no search)
|
||||
this._searchDebounceId = null; // pending GLib timeout for debounce
|
||||
this._searchEpoch = 0; // monotonic counter to discard stale search responses
|
||||
|
||||
this._visible = false;
|
||||
this._grab = null; // Clutter.Grab from Main.pushModal
|
||||
this._hoveredWidget = null; // currently mouse-hovered ClipboardItem
|
||||
this._activeWidget = null; // most recently copied item
|
||||
|
||||
this._buildUI();
|
||||
// Trigger the initial load as soon as the daemon owns its bus name.
|
||||
// At extension boot the daemon may still be starting (its D-Bus name
|
||||
// not yet owned); the proxy fires notify::g-name-owner when that
|
||||
// changes, so we listen for the first transition to a non-null owner
|
||||
// and only then issue GetHistory. If the daemon is already up, the
|
||||
// owner is already set and we load immediately.
|
||||
this._tryInitialLoad();
|
||||
this._nameOwnerId = this._proxy.connect('notify::g-name-owner',
|
||||
() => this._tryInitialLoad());
|
||||
}
|
||||
|
||||
_tryInitialLoad() {
|
||||
if (this._initialLoaded) return;
|
||||
if (!this._proxy.g_name_owner) return;
|
||||
this._initialLoaded = true;
|
||||
this._loadHistory(0, this._pageSize).catch(e =>
|
||||
console.error('[Strata] initial _loadHistory failed:', e));
|
||||
}
|
||||
|
||||
|
||||
_buildUI() {
|
||||
// Overlay container - sits above all windows.
|
||||
this._overlay = new St.Widget({
|
||||
layout_manager: new Clutter.FixedLayout(),
|
||||
visible: false,
|
||||
reactive: true,
|
||||
});
|
||||
Main.layoutManager.addChrome(this._overlay);
|
||||
|
||||
// Close when clicking outside the panel box.
|
||||
// With pushModal active, ALL pointer events are delivered to _overlay,
|
||||
// so this handler sees every click on screen.
|
||||
this._overlay.connect('button-press-event', (_actor, event) => {
|
||||
const [cx, cy] = event.get_coords();
|
||||
const [bx, by] = this._box.get_transformed_position();
|
||||
const [bw, bh] = this._box.get_transformed_size();
|
||||
if (cx >= bx && cx <= bx + bw && cy >= by && cy <= by + bh)
|
||||
return Clutter.EVENT_PROPAGATE;
|
||||
this.close();
|
||||
return Clutter.EVENT_STOP;
|
||||
});
|
||||
|
||||
// pushModal intercepts all motion events at the overlay level, so CSS
|
||||
// :hover never fires on child actors. Manually track which item is
|
||||
// under the cursor and toggle the JS-driven 'strata-item-hovered' class.
|
||||
this._overlay.connect('motion-event', (_actor, event) => {
|
||||
const [cx, cy] = event.get_coords();
|
||||
let found = null;
|
||||
for (const widget of this._widgets.values()) {
|
||||
if (!widget.visible) continue;
|
||||
const [wx, wy] = widget.get_transformed_position();
|
||||
const [ww, wh] = widget.get_transformed_size();
|
||||
if (cx >= wx && cx <= wx + ww && cy >= wy && cy <= wy + wh) {
|
||||
found = widget;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found !== this._hoveredWidget) {
|
||||
this._hoveredWidget?.remove_style_class_name('strata-item-hovered');
|
||||
this._hoveredWidget = found;
|
||||
found?.add_style_class_name('strata-item-hovered');
|
||||
}
|
||||
return Clutter.EVENT_PROPAGATE;
|
||||
});
|
||||
|
||||
this._overlay.connect('leave-event', () => {
|
||||
this._hoveredWidget?.remove_style_class_name('strata-item-hovered');
|
||||
this._hoveredWidget = null;
|
||||
return Clutter.EVENT_PROPAGATE;
|
||||
});
|
||||
|
||||
// Panel box
|
||||
this._box = new St.BoxLayout({
|
||||
style_class: 'strata-panel',
|
||||
vertical: true,
|
||||
reactive: true,
|
||||
});
|
||||
|
||||
// Header row: title + clear button
|
||||
const header = new St.BoxLayout({
|
||||
style_class: 'strata-header',
|
||||
x_expand: true,
|
||||
});
|
||||
const title = new St.Label({
|
||||
text: 'Clipboard',
|
||||
style_class: 'strata-title',
|
||||
x_expand: true,
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
const clearBtn = new St.Button({
|
||||
label: 'Clear all',
|
||||
style_class: 'strata-clear-btn',
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
clearBtn.connect('clicked', () => this._clearAll());
|
||||
header.add_child(title);
|
||||
header.add_child(clearBtn);
|
||||
|
||||
// Search box
|
||||
this._searchEntry = new St.Entry({
|
||||
hint_text: 'Search…',
|
||||
style_class: 'strata-search',
|
||||
x_expand: true,
|
||||
can_focus: true,
|
||||
});
|
||||
this._searchEntry.get_clutter_text().connect('text-changed', () => {
|
||||
this._scheduleSearch(this._searchEntry.get_text());
|
||||
});
|
||||
// Down arrow from search box moves focus to the first item.
|
||||
this._searchEntry.get_clutter_text().connect('key-press-event', (_actor, event) => {
|
||||
if (event.get_key_symbol() === Clutter.KEY_Down) {
|
||||
this._focusItem(0);
|
||||
return Clutter.EVENT_STOP;
|
||||
}
|
||||
return Clutter.EVENT_PROPAGATE;
|
||||
});
|
||||
|
||||
// Item list
|
||||
this._scrollView = new St.ScrollView({
|
||||
style_class: 'strata-scroll',
|
||||
x_expand: true,
|
||||
y_expand: true,
|
||||
overlay_scrollbars: true,
|
||||
});
|
||||
this._itemList = new St.BoxLayout({
|
||||
style_class: 'strata-item-list',
|
||||
vertical: true,
|
||||
x_expand: true,
|
||||
});
|
||||
this._scrollView.set_child(this._itemList);
|
||||
|
||||
// Load more items when scrolled near the bottom.
|
||||
const vadj = this._scrollView.get_vadjustment();
|
||||
if (vadj) {
|
||||
vadj.connect('notify::value', () => this._maybeLoadMore());
|
||||
// Also re-check when the list grows (a new page just appended).
|
||||
vadj.connect('notify::upper', () => this._maybeLoadMore());
|
||||
}
|
||||
|
||||
// Assemble
|
||||
this._box.add_child(header);
|
||||
this._box.add_child(this._searchEntry);
|
||||
this._box.add_child(this._scrollView);
|
||||
this._overlay.add_child(this._box);
|
||||
|
||||
// ESC to close
|
||||
this._overlay.connect('key-press-event', (_actor, event) => {
|
||||
if (event.get_key_symbol() === Clutter.KEY_Escape) {
|
||||
this.close();
|
||||
return Clutter.EVENT_STOP;
|
||||
}
|
||||
return Clutter.EVENT_PROPAGATE;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
toggle() {
|
||||
this._visible ? this.close() : this.open();
|
||||
}
|
||||
|
||||
open() {
|
||||
if (this._visible) return;
|
||||
this._visible = true;
|
||||
this._overlay.show();
|
||||
this._positionPanel();
|
||||
// Re-position after one frame so bottom/center use the fully allocated height.
|
||||
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
|
||||
if (this._visible) this._positionPanel();
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
|
||||
// pushModal grabs ALL input and delivers it to _overlay - this is the
|
||||
// proper GNOME Shell pattern for "click outside to close" dialogs.
|
||||
this._grab = Main.pushModal(this._overlay, {
|
||||
actionMode: Shell.ActionMode.POPUP,
|
||||
});
|
||||
|
||||
this._searchEntry.set_text('');
|
||||
// Reset to non-search view - but DON'T reload; history may already be populated.
|
||||
this._searchQuery = '';
|
||||
// If the initial history load failed (e.g. daemon wasn't ready at startup),
|
||||
// retry now that the user is asking to see the list.
|
||||
if (this._items.length === 0 && this._loadedOffset === 0 && this._hasMore) {
|
||||
this._loadHistory(0, this._pageSize).catch(e =>
|
||||
console.error('[Strata] open: history reload failed:', e));
|
||||
}
|
||||
global.stage.set_key_focus(this._searchEntry.get_clutter_text());
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this._visible) return;
|
||||
this._visible = false;
|
||||
// Clear hover state since the panel is closing.
|
||||
this._hoveredWidget?.remove_style_class_name('strata-item-hovered');
|
||||
this._hoveredWidget = null;
|
||||
if (this._grab) {
|
||||
Main.popModal(this._grab);
|
||||
this._grab = null;
|
||||
}
|
||||
this._overlay.hide();
|
||||
}
|
||||
|
||||
/** Prepend a newly added item. Wrapped in try/catch so one bad item can't
|
||||
* break subsequent additions. */
|
||||
prependItem(id, mimeType, preview) {
|
||||
try {
|
||||
this._items.unshift({ id, mimeType, preview });
|
||||
this._loadedOffset += 1;
|
||||
|
||||
if (this._widgets.has(id)) {
|
||||
this._widgets.get(id)?.destroy();
|
||||
this._widgets.delete(id);
|
||||
}
|
||||
|
||||
// While searching, only render if the new item matches the active query.
|
||||
if (this._searchQuery && !this._matchesSearch(preview, mimeType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const widget = this._makeItemWidget(id, mimeType, preview);
|
||||
this._widgets.set(id, widget);
|
||||
this._itemList.insert_child_at_index(widget.actor, 0);
|
||||
this._setActiveWidget(widget);
|
||||
} catch (e) {
|
||||
console.error(`[Strata] prependItem failed for id=${id} mime=${mimeType}:`, e);
|
||||
this._items = this._items.filter(i => i.id !== id);
|
||||
}
|
||||
}
|
||||
|
||||
// Substring match used for newly-arrived items while a search is active.
|
||||
// Approximates FTS5 prefix match until the next debounced search refresh.
|
||||
_matchesSearch(preview, mimeType) {
|
||||
if (mimeType?.startsWith('image/')) return false;
|
||||
if (!preview) return false;
|
||||
const haystack = preview.toLowerCase();
|
||||
return this._searchQuery.toLowerCase().split(/\s+/)
|
||||
.filter(Boolean).every(tok => haystack.includes(tok));
|
||||
}
|
||||
|
||||
removeItem(id) {
|
||||
this._items = this._items.filter(i => i.id !== id);
|
||||
const widget = this._widgets.get(id);
|
||||
if (widget) {
|
||||
const wasActive = widget === this._activeWidget;
|
||||
const wasHovered = widget === this._hoveredWidget;
|
||||
if (wasActive) this._activeWidget = null;
|
||||
if (wasHovered) this._hoveredWidget = null;
|
||||
widget.destroy();
|
||||
this._widgets.delete(id);
|
||||
// Re-assign active to the new first visible item.
|
||||
if (wasActive) {
|
||||
const first = this._getVisibleItems()[0];
|
||||
if (first) this._setActiveWidget(first);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearItems() {
|
||||
this._items = [];
|
||||
this._hoveredWidget = null;
|
||||
this._activeWidget = null;
|
||||
this._widgets.forEach(w => w.destroy());
|
||||
this._widgets.clear();
|
||||
this._itemList.destroy_all_children();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._pageSizeChangedId) {
|
||||
this._settings.disconnect(this._pageSizeChangedId);
|
||||
this._pageSizeChangedId = 0;
|
||||
}
|
||||
if (this._nameOwnerId) {
|
||||
this._proxy.disconnect(this._nameOwnerId);
|
||||
this._nameOwnerId = 0;
|
||||
}
|
||||
if (this._initialLoadId) {
|
||||
GLib.Source.remove(this._initialLoadId);
|
||||
this._initialLoadId = null;
|
||||
}
|
||||
if (this._searchDebounceId) {
|
||||
GLib.Source.remove(this._searchDebounceId);
|
||||
this._searchDebounceId = null;
|
||||
}
|
||||
this.close();
|
||||
this.clearItems();
|
||||
Main.layoutManager.removeChrome(this._overlay);
|
||||
this._overlay.destroy();
|
||||
this._overlay = null;
|
||||
}
|
||||
|
||||
|
||||
async _loadHistory(offset, limit) {
|
||||
if (!this._overlay) return 0;
|
||||
try {
|
||||
const [json] = await this._proxy.GetHistoryAsync(offset, limit);
|
||||
if (!this._overlay) return 0;
|
||||
const items = JSON.parse(json);
|
||||
const BATCH = 20;
|
||||
for (let i = 0; i < items.length; i += BATCH) {
|
||||
if (!this._overlay) return items.length;
|
||||
await new Promise(resolve =>
|
||||
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
|
||||
const end = Math.min(i + BATCH, items.length);
|
||||
for (let j = i; j < end; j++) this._appendItemFromMeta(items[j]);
|
||||
resolve();
|
||||
return GLib.SOURCE_REMOVE;
|
||||
})
|
||||
);
|
||||
}
|
||||
this._loadedOffset = offset + items.length;
|
||||
this._hasMore = items.length >= limit;
|
||||
if (offset === 0 && items.length > 0) {
|
||||
const firstWidget = this._widgets.get(items[0].id);
|
||||
if (firstWidget) this._setActiveWidget(firstWidget);
|
||||
}
|
||||
return items.length;
|
||||
} catch (e) {
|
||||
console.error('[Strata] _loadHistory failed:', e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
_appendItemFromMeta(meta) {
|
||||
try {
|
||||
const id = meta.id;
|
||||
const mimeType = meta.mime_type;
|
||||
const preview = meta.content_text ?? '';
|
||||
if (this._widgets.has(id)) return;
|
||||
|
||||
this._items.push({ id, mimeType, preview });
|
||||
const widget = this._makeItemWidget(id, mimeType, preview);
|
||||
this._widgets.set(id, widget);
|
||||
this._itemList.add_child(widget.actor);
|
||||
} catch (e) {
|
||||
console.error(`[Strata] _appendItemFromMeta failed for id=${meta?.id}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
_maybeLoadMore() {
|
||||
if (this._searchQuery) return;
|
||||
if (!this._hasMore || this._loadingMore) return;
|
||||
const adj = this._scrollView.get_vadjustment();
|
||||
if (!adj || adj.upper <= adj.page_size) return;
|
||||
const distanceToBottom = adj.upper - (adj.value + adj.page_size);
|
||||
if (distanceToBottom > LOAD_MORE_THRESHOLD) return;
|
||||
|
||||
this._loadingMore = true;
|
||||
this._loadHistory(this._loadedOffset, this._pageSize)
|
||||
.finally(() => { this._loadingMore = false; });
|
||||
}
|
||||
|
||||
|
||||
_makeItemWidget(id, mimeType, preview) {
|
||||
const widget = new ClipboardItem(id, mimeType, preview, {
|
||||
proxy: this._proxy,
|
||||
thumbCache: this._thumbCache,
|
||||
});
|
||||
widget.connect('activate', () => {
|
||||
this._setActiveWidget(widget);
|
||||
if (this._settings?.get_boolean('move-activated-to-top'))
|
||||
this._moveItemToTop(id, widget);
|
||||
this._onItemActivated(id);
|
||||
});
|
||||
widget.connect('delete', () => {
|
||||
this._proxy.DeleteItemRemote(id, () => {});
|
||||
});
|
||||
widget.connect('key-press-event', (_actor, event) => {
|
||||
const sym = event.get_key_symbol();
|
||||
const items = this._getVisibleItems();
|
||||
const idx = items.indexOf(widget);
|
||||
if (sym === Clutter.KEY_Down) {
|
||||
if (idx < items.length - 1) {
|
||||
global.stage.set_key_focus(items[idx + 1]);
|
||||
this._ensureVisible(items[idx + 1]);
|
||||
}
|
||||
return Clutter.EVENT_STOP;
|
||||
}
|
||||
if (sym === Clutter.KEY_Up) {
|
||||
if (idx <= 0) {
|
||||
global.stage.set_key_focus(this._searchEntry.get_clutter_text());
|
||||
} else {
|
||||
global.stage.set_key_focus(items[idx - 1]);
|
||||
this._ensureVisible(items[idx - 1]);
|
||||
}
|
||||
return Clutter.EVENT_STOP;
|
||||
}
|
||||
if (sym === Clutter.KEY_Escape) {
|
||||
this.close();
|
||||
return Clutter.EVENT_STOP;
|
||||
}
|
||||
return Clutter.EVENT_PROPAGATE;
|
||||
});
|
||||
return widget;
|
||||
}
|
||||
|
||||
/** Focus the item at visible-list index `idx` (clamped). */
|
||||
_focusItem(idx) {
|
||||
const items = this._getVisibleItems();
|
||||
if (items.length === 0) return;
|
||||
global.stage.set_key_focus(items[Math.max(0, Math.min(idx, items.length - 1))]);
|
||||
}
|
||||
|
||||
/** All currently-visible item actors in order. */
|
||||
_getVisibleItems() {
|
||||
return this._itemList.get_children().filter(c => c.visible);
|
||||
}
|
||||
|
||||
_ensureVisible(widget) {
|
||||
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
|
||||
try {
|
||||
const adj = this._scrollView.get_vadjustment();
|
||||
if (!adj || adj.page_size === 0) return GLib.SOURCE_REMOVE;
|
||||
|
||||
// Allocation box coords share the same space as adj.value.
|
||||
const box = widget.get_allocation_box();
|
||||
const cur = adj.value;
|
||||
const pageSize = adj.page_size;
|
||||
|
||||
if (box.y1 < cur)
|
||||
adj.value = box.y1;
|
||||
else if (box.y2 > cur + pageSize)
|
||||
adj.value = box.y2 - pageSize;
|
||||
} catch (e) {
|
||||
console.error('[Strata] ensureVisible error:', e);
|
||||
}
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
|
||||
async _onItemActivated(id) {
|
||||
try {
|
||||
const [mimeType, contentB64] = await this._proxy.GetItemContentAsync(id);
|
||||
this._writeToClipboard(mimeType, GLib.base64_decode(contentB64));
|
||||
} catch (e) {
|
||||
console.error('[Strata] Paste error:', e);
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
|
||||
_writeToClipboard(mimeType, bytes) {
|
||||
try {
|
||||
if (mimeType.startsWith('text/') || mimeType === 'UTF8_STRING') {
|
||||
const text = new TextDecoder('utf-8').decode(bytes);
|
||||
St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, text);
|
||||
} else {
|
||||
const source = Meta.SelectionSourceMemory.new(
|
||||
mimeType,
|
||||
GLib.Bytes.new(bytes)
|
||||
);
|
||||
global.display.get_selection().set_owner(
|
||||
Meta.SelectionType.SELECTION_CLIPBOARD,
|
||||
source
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Strata] Clipboard write error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async _clearAll() {
|
||||
try {
|
||||
await this._proxy.ClearHistoryAsync();
|
||||
} catch (e) {
|
||||
console.error('[Strata] ClearHistory error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Debounce search box keystrokes; collapse rapid edits into one query. */
|
||||
_scheduleSearch(query) {
|
||||
if (this._searchDebounceId) {
|
||||
GLib.Source.remove(this._searchDebounceId);
|
||||
this._searchDebounceId = null;
|
||||
}
|
||||
const trimmed = (query ?? '').trim();
|
||||
this._searchDebounceId = GLib.timeout_add(
|
||||
GLib.PRIORITY_DEFAULT, SEARCH_DEBOUNCE_MS, () => {
|
||||
this._searchDebounceId = null;
|
||||
this._runSearch(trimmed).catch(e =>
|
||||
console.error('[Strata] search failed:', e));
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply a search query (empty = reset to the recent-history view).
|
||||
* All state-mutating writes are guarded with an epoch so a fast-cancelled
|
||||
* search cannot overwrite the results of a newer one. */
|
||||
async _runSearch(query) {
|
||||
const epoch = ++this._searchEpoch;
|
||||
if (!query) {
|
||||
this._searchQuery = '';
|
||||
this._clearListDom();
|
||||
this._items = [];
|
||||
this._loadedOffset = 0;
|
||||
this._hasMore = true;
|
||||
// Don't await: if the user starts a new search mid-load, the epoch
|
||||
// guard inside _appendItemFromMeta's caller would still let stale
|
||||
// rows leak in. So check the epoch before letting any rows in.
|
||||
const ePromise = this._loadHistory(0, this._pageSize);
|
||||
ePromise.then(() => {
|
||||
if (epoch !== this._searchEpoch) {
|
||||
// A newer search superseded us; nuke whatever we appended.
|
||||
this._clearListDom();
|
||||
this._items = [];
|
||||
}
|
||||
}).catch(e => console.error('[Strata] reset-history failed:', e));
|
||||
return;
|
||||
}
|
||||
|
||||
this._searchQuery = query;
|
||||
let json;
|
||||
try {
|
||||
[json] = await this._proxy.SearchHistoryAsync(query, SEARCH_LIMIT);
|
||||
} catch (e) {
|
||||
console.error('[Strata] SearchHistory D-Bus error:', e);
|
||||
return;
|
||||
}
|
||||
if (epoch !== this._searchEpoch || !this._overlay) return; // stale or destroyed
|
||||
|
||||
let results;
|
||||
try {
|
||||
results = JSON.parse(json);
|
||||
} catch (e) {
|
||||
console.error('[Strata] SearchHistory: bad JSON:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
this._clearListDom();
|
||||
this._items = [];
|
||||
// Disable pagination while searching - the search response is already
|
||||
// bounded by SEARCH_LIMIT, scrolling shouldn't pull more.
|
||||
this._hasMore = false;
|
||||
this._loadedOffset = 0;
|
||||
|
||||
const BATCH = 20;
|
||||
for (let i = 0; i < results.length; i += BATCH) {
|
||||
if (epoch !== this._searchEpoch || !this._overlay) return;
|
||||
await new Promise(resolve =>
|
||||
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
|
||||
const end = Math.min(i + BATCH, results.length);
|
||||
for (let j = i; j < end; j++) this._appendItemFromMeta(results[j]);
|
||||
resolve();
|
||||
return GLib.SOURCE_REMOVE;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear down all rendered item widgets (without touching the data model). */
|
||||
_clearListDom() {
|
||||
this._hoveredWidget = null;
|
||||
this._activeWidget = null;
|
||||
this._widgets.forEach(w => w.destroy());
|
||||
this._widgets.clear();
|
||||
this._itemList.destroy_all_children();
|
||||
}
|
||||
|
||||
_setActiveWidget(widget) {
|
||||
this._activeWidget?.remove_style_class_name('strata-item-active');
|
||||
this._activeWidget = widget ?? null;
|
||||
widget?.add_style_class_name('strata-item-active');
|
||||
}
|
||||
|
||||
_moveItemToTop(id, widget) {
|
||||
// Move data model entry to front.
|
||||
const idx = this._items.findIndex(i => i.id === id);
|
||||
if (idx > 0) {
|
||||
const [entry] = this._items.splice(idx, 1);
|
||||
this._items.unshift(entry);
|
||||
}
|
||||
// Move actor to position 0 in the list.
|
||||
this._itemList.set_child_at_index(widget, 0);
|
||||
}
|
||||
|
||||
_positionPanel() {
|
||||
const monitor = Main.layoutManager.primaryMonitor;
|
||||
if (!monitor) return;
|
||||
|
||||
const PANEL_W = Math.min(
|
||||
this._settings?.get_int('panel-width') ?? 480,
|
||||
monitor.width * 0.9
|
||||
);
|
||||
const MAX_H = Math.min(
|
||||
this._settings?.get_int('panel-max-height') ?? 600,
|
||||
monitor.height * 0.85
|
||||
);
|
||||
|
||||
this._box.set_width(PANEL_W);
|
||||
// Let height shrink to content; cap at MAX_H so the scroll view kicks in
|
||||
// when there are many items.
|
||||
this._box.set_height(-1);
|
||||
this._box.style = `max-height: ${MAX_H}px;`;
|
||||
|
||||
const position = this._settings?.get_string('panel-position') ?? 'top-center';
|
||||
const MARGIN = 16; // px gap from screen edge
|
||||
|
||||
let x, y;
|
||||
// Horizontal
|
||||
if (position.endsWith('left'))
|
||||
x = monitor.x + MARGIN;
|
||||
else if (position.endsWith('right'))
|
||||
x = monitor.x + monitor.width - PANEL_W - MARGIN;
|
||||
else // center
|
||||
x = monitor.x + Math.round((monitor.width - PANEL_W) / 2);
|
||||
|
||||
// Vertical - snap to shell bars so the panel feels native
|
||||
const topBarH = Main.layoutManager.panelBox?.height ?? 32;
|
||||
if (position.startsWith('top')) {
|
||||
y = monitor.y + topBarH + MARGIN;
|
||||
} else {
|
||||
// For bottom/center we need the actual rendered height, not MAX_H.
|
||||
// get_preferred_height(-1) returns [minH, naturalH] synchronously.
|
||||
const [, naturalH] = this._box.get_preferred_height(-1);
|
||||
const actualH = Math.min(naturalH, MAX_H);
|
||||
if (position.startsWith('bottom'))
|
||||
y = monitor.y + monitor.height - actualH - MARGIN;
|
||||
else // center
|
||||
y = monitor.y + Math.round((monitor.height - actualH) / 2);
|
||||
}
|
||||
|
||||
this._box.set_position(Math.round(x), Math.round(y));
|
||||
|
||||
// Overlay fills the screen for click-outside detection.
|
||||
this._overlay.set_position(monitor.x, monitor.y);
|
||||
this._overlay.set_size(monitor.width, monitor.height);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue