A Process's exited and streamFinished signals aren't guaranteed to fire in order, and several services decided an outcome on whichever fired first: KdeConnect could report a successful file transfer as failed if exited landed before the real stdout payload; Clipboard could present a failed history query as an empty-but-healthy one; Brightness could strand the last queued write of a drag; SoundFeedback and SystemLocale could drop or misapply a rapid second toggle/click because re-arming an already-running Process is a no-op. All five now wait for both signals and let the authoritative one decide, matching the pattern HomeAssistantConfig.qml already used correctly. Health's "copy report" never enabled stdin, so it copied nothing while claiming success. Capture announced every recording as saved regardless of the recorder's actual exit code. Connectivity never restarted Bluetooth discovery when the adapter was enabled from an already-open page. CalendarAgenda left the UI in "loading" forever if its helper died at startup, and the helper itself could crash unguarded instead of reporting unavailable. Geocoding silently dropped a query typed while the previous one was still in flight. Notifs leaked tracked-but-undisplayed notifications under Do Not Disturb, and dismissAll() skipped them. Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
280 lines
12 KiB
QML
280 lines
12 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
|
|
|
|
// 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._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, "''");
|
|
}
|
|
}
|