241 lines
9.9 KiB
QML
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, "''");
|
|
}
|
|
}
|