pragma Singleton // ───────────────────────────────────────────────────────────────────────────── // Clipboard history — read out of Vicinae's store. // // Vicinae (the launcher) already runs a clipboard watcher, so this service is // deliberately a *reader* and nothing else. A second `wl-paste --watch` would // record every copy twice and give the desktop two disagreeing opinions about // which pastes are sensitive enough to conceal. // // Two files back every entry: // ~/.local/share/vicinae/clipboard.db metadata + a 50-char preview // ~/.local/share/vicinae/clipboard-data/ the full payload, text or binary // // The stored preview is truncated far too short to fill a row, so the query // pulls the first few hundred bytes out of the payload file instead (sqlite's // readfile()) and falls back to the stored preview if that file has been reaped. // // Read-only and on demand: the query runs when refresh() is called, never on a // timer. Nothing in here wakes up while the popover is closed. // ───────────────────────────────────────────────────────────────────────────── import Quickshell import Quickshell.Io import QtQuick Singleton { id: root // ── Entry kinds ───────────────────────────────────────────────────────── // Vicinae's `selection.kind` column, confirmed empirically by copying one // of each: plain text, a URL, a PNG and a file drag. readonly property int kindText: 1 readonly property int kindLink: 2 readonly property int kindImage: 3 readonly property int kindFile: 4 readonly property string dbPath: Quickshell.env("HOME") + "/.local/share/vicinae/clipboard.db" readonly property string dataDir: Quickshell.env("HOME") + "/.local/share/vicinae/clipboard-data" // How many rows to load. The popover scrolls, so this is a memory budget // rather than a display limit; anything older is a job for the launcher. readonly property int limit: 50 // Newest first, pinned entries hoisted above them. Each element: // { id, offerId, mime, kind, source, host, pinned, ts, size, // preview, encrypted, textual, typeLabel, dataPath } property var entries: [] // True between refresh() and the query finishing. The popover uses it to // tell "nothing copied yet" apart from "not loaded yet". property bool loading: false // False if the query failed outright — a missing database, or Vicinae not // installed. Distinct from an empty history. property bool available: true // query's `exited` and its stdout `streamFinished` are not guaranteed to // fire in a particular order (the same signal-ordering hazard // HomeAssistantConfig.qml's settle-both pattern guards against). These // track which of the two have arrived for the query currently in flight // so the result is only finalized once both are known -- otherwise a // failed query's empty stdout could be parsed as "empty history" before // the non-zero exit code is seen, or vice versa. property bool _queryExited: false property int _queryExitCode: 0 property bool _queryStdoutDone: false property string _queryStdoutText: "" // Wall-clock seconds at the moment `entries` was filled. Relative times are // rendered against this rather than against a live clock, so a row's label // cannot change while the user is reading it and nothing has to tick. property real queriedAt: 0 signal refreshed // Reload the history. Cheap enough to call on every popover open; a call // made while a query is already in flight is dropped rather than queued, // because the in-flight result is already at most milliseconds stale. function refresh(): void { if (query.running) return; root.loading = true; root._queryExited = false; root._queryStdoutDone = false; query.running = true; } // Put an entry back on the clipboard. The payload file is the source of // truth — the preview in `entries` is truncated, so copying that back would // silently hand the user a fragment of what they asked for. function copyEntry(id: string): void { const entry = root.entries.find(e => e.id === id); if (!entry || entry.encrypted) return; // Arguments after the -c script become $0..$n, so neither the path nor // the mime type is ever spliced into shell source. wl-copy has no // read-from-file flag, hence the redirect. copy.command = ["sh", "-c", 'exec wl-copy --type "$2" < "$1"', "qs-clipboard", entry.dataPath, entry.mime]; copy.running = true; } // "just now" / "5m" / "2h" / "Mon" / "Mar 4", against the time the list was // loaded. Deliberately terse: this sits in the corner of a dense row. function relativeTime(ts: real): string { const delta = Math.max(0, root.queriedAt - ts); if (delta < 60) return "just now"; if (delta < 3600) return Math.floor(delta / 60) + "m"; if (delta < 86400) return Math.floor(delta / 3600) + "h"; const date = new Date(ts * 1000); if (delta < 7 * 86400) return Qt.formatDateTime(date, "ddd"); return Qt.formatDateTime(date, "MMM d"); } // ── The query ─────────────────────────────────────────────────────────── // -readonly so a click in the shell can never take a write lock on a // database another process owns; -json because clipboard text is full of // newlines, pipes and quotes and any separator we picked would appear in it. // // The payload file is only read for unencrypted text: casting an encrypted // blob or a PNG to text yields noise, and those rows carry a usable label in // `text_preview` already ("Image (60x60)"). readonly property string sql: ` select s.id as id, o.id as offerId, s.preferred_mime_type as mime, s.kind as kind, o.encryption_type as enc, coalesce(s.source, '') as source, coalesce(o.url_host, '') as host, s.pinned_at is not null as pinned, s.updated_at as ts, o.size as size, case when s.preferred_mime_type like 'text/%' and o.encryption_type = 0 then coalesce(cast(substr(readfile('${root._sqlLiteral(root.dataDir)}/' || o.id), 1, 400) as text), o.text_preview) else o.text_preview end as preview from selection s join data_offer o on o.selection_id = s.id and o.mime_type = s.preferred_mime_type where o.id = ( select min(o2.id) from data_offer o2 where o2.selection_id = s.id and o2.mime_type = s.preferred_mime_type ) order by s.pinned_at is null, s.pinned_at desc, s.updated_at desc limit ${root.limit}` Process { id: query command: ["sqlite3", "-readonly", "-json", root.dbPath, root.sql] stdout: StdioCollector { onStreamFinished: { root._queryStdoutDone = true; root._queryStdoutText = this.text; root._settleQuery(); } } onExited: exitCode => { root.loading = false; root._queryExited = true; root._queryExitCode = exitCode; root._settleQuery(); } } Process { id: copy onExited: exitCode => { // Silent success, loud failure: a copy that quietly did nothing // leaves the user pasting whatever was there before. if (exitCode !== 0) console.warn("Clipboard: wl-copy exited", exitCode); } } // Called from both query.onExited and its stdout streamFinished. Only // finalizes once both signals have arrived, since their firing order is // not guaranteed -- see the _queryExited / _queryStdoutDone comment // above. A non-zero exit always means the history is unavailable, // regardless of what (if anything) stdout produced; only a clean exit // reaches _parse, where empty stdout is legitimately "no history yet". function _settleQuery(): void { if (!root._queryExited || !root._queryStdoutDone) return; const exitCode = root._queryExitCode; const text = root._queryStdoutText; root._queryExited = false; root._queryStdoutDone = false; if (exitCode !== 0) { root.available = false; root.entries = []; root.refreshed(); return; } root._parse(text); } function _parse(text: string): void { root.available = true; root.queriedAt = Date.now() / 1000; // sqlite3 -json prints nothing at all for an empty result set. if (!text || !text.trim()) { root.entries = []; root.refreshed(); return; } try { root.entries = JSON.parse(text).map(row => root._normalize(row)); } catch (e) { console.warn("Clipboard: could not parse history —", e); root.entries = []; root.available = false; } root.refreshed(); } // Turn a raw row into what the UI actually asks questions of, so no // delegate has to know about column names or Vicinae's integer kinds. function _normalize(row: var): var { const encrypted = row.enc !== 0; const textual = row.kind === root.kindText || row.kind === root.kindLink; const raw = row.preview || ""; return { id: row.id, offerId: row.offerId, mime: row.mime, kind: row.kind, source: row.source, host: row.host, pinned: row.pinned === 1, ts: row.ts, size: row.size, encrypted: encrypted, textual: textual, typeLabel: root._typeLabel(row.kind, row.mime), // Newlines and tabs become spaces: a row is one line of a list, and // a pasted shell script must not turn it into a paragraph. preview: encrypted ? "" : raw.replace(/\s+/g, " ").trim(), dataPath: root.dataDir + "/" + row.offerId }; } // Short chip text for entries with nothing readable to show. function _typeLabel(kind: int, mime: string): string { switch (kind) { case root.kindImage: // "image/png" -> "PNG". Falls back to the whole type if it is odd. return (mime.split("/")[1] || mime).split(";")[0].toUpperCase(); case root.kindFile: return "Files"; case root.kindLink: return "Link"; default: return "Text"; } } // Escape a path for embedding in a SQL string literal. HOME is not // attacker-controlled, but a stray apostrophe would break the query. function _sqlLiteral(value: string): string { return value.replace(/'/g, "''"); } }