Files
Panama/config/dot/quickshell/services/Clipboard.qml
T

241 lines
9.9 KiB
QML

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/<offer> 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
// 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;
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._parse(this.text)
}
onExited: exitCode => {
root.loading = false;
if (exitCode !== 0) {
root.available = false;
root.entries = [];
root.refreshed();
}
}
}
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);
}
}
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._normalise(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 _normalise(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, "''");
}
}