Skip to content

Commit b815b95

Browse files
authored
Merge pull request #728 from blackbartblues/fix/clipper-v2.4.2-wlcopyproc
fix(clipper): ReferenceError teardown + atomic write data-loss hotfix (v2.4.3)
2 parents ee2c969 + b0f4594 commit b815b95

3 files changed

Lines changed: 92 additions & 20 deletions

File tree

clipper/CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
# Clipper Plugin - Comprehensive Changelog
22

3+
## Version 2.4.3 (2026-04-22)
4+
5+
### Data-loss hotfix: atomic writes for pinned items and notecards
6+
7+
Fixes a data-loss bug that could silently destroy pinned items (`pinned.json`) or individual notecards (`notecards/*.json`) under rare timing conditions. A 0-byte ghost file left on disk was traced back to three compounding issues in the save path:
8+
9+
1. **Non-atomic shell redirection.** All writes used `base64 -d > "$file"`, which opens the target with `O_TRUNC` **before** decoding and writing. If the process was interrupted between truncate and write (shell restart, kill, OOM, base64 failure), the file was left at 0 bytes with no recovery possible.
10+
2. **Rename race in `updateNoteCard`.** When a note's title changed, the old file was deleted via a parallel `rm` `execDetached` call issued alongside the new `saveNoteCard`. If the `rm` completed but the save failed, the note vanished entirely.
11+
3. **Empty-base64 edge case.** If `stringToBase64()` returned an empty string (e.g. from a malformed note object), the shell pipeline still truncated the target, producing a guaranteed 0-byte file.
12+
13+
**Fix:** new `atomicWriteBase64(filePath, base64, oldFilePath)` helper in `Main.qml`:
14+
15+
- Writes to a temp file (`<file>.tmp`) first, verifies it is non-empty (`[ -s "$t" ]`), then atomically renames over the target with `mv -f`.
16+
- Only removes the old file **after** the new file is safely in place (and only when the old path differs from the new path).
17+
- Passes `filePath`, `oldFilePath`, and `base64` via argv (not string interpolation) — shell-injection safe and robust against filenames with spaces/quotes.
18+
- Refuses empty writes up front (`base64.length === 0`) and logs a warning instead of truncating the target.
19+
- `saveNoteCard` now validates `note.id` and `JSON.stringify` length ≥ 10 before writing.
20+
21+
Applied to `savePinnedFile`, `saveNoteCard`, `updateNoteCard`, and `exportNoteCard`.
22+
23+
Also: `Component.onCompleted` now sweeps any stale `*.json.tmp` files left over from previous interrupted writes, so a crash mid-hotfix cannot leak temp files indefinitely.
24+
25+
## Version 2.4.2 (2026-04-22)
26+
27+
### Fix ReferenceError on plugin teardown
28+
29+
- Removed a dead `wlCopyProc` reference in `Component.onDestruction` (`Main.qml`) that threw `ReferenceError: wlCopyProc is not defined` when the panel or shell was torn down. The id was left over from an earlier refactor; wl-copy usage already lives in `copyToClipboardProc` and direct `Quickshell.execDetached(["wl-copy", ...])` calls, so no replacement logic is needed.
30+
331
## Version 2.4.1 (2026-04-21)
432

533
### Fix Qt.btoa deprecation warnings

clipper/Main.qml

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -386,20 +386,45 @@ Item {
386386
return Qt.btoa(new Uint8Array(bytes));
387387
}
388388

389+
// Atomic write: decode base64 to a temp file, verify non-empty, then rename
390+
// onto the target. Optionally rm a stale filename (from a rename) only after
391+
// the new file is verified on disk. All paths are passed through argv — not
392+
// interpolated into the shell string — so filenames with spaces or shell
393+
// metacharacters cannot break the command. Replaces the prior
394+
// `echo | base64 -d > file` pattern which truncated the target before any
395+
// data flowed and left 0-byte files whenever the shell was killed between
396+
// the open-for-truncate syscall and the write (shutdown, panel teardown,
397+
// interrupted process, empty base64, etc.). On any failure, the original
398+
// file is preserved untouched.
399+
function atomicWriteBase64(filePath, base64, oldFilePath) {
400+
if (!filePath || typeof filePath !== "string") {
401+
Logger.w("Clipper", "atomicWriteBase64: missing filePath");
402+
return;
403+
}
404+
if (!base64 || base64.length === 0) {
405+
Logger.w("Clipper", "atomicWriteBase64: refusing empty write to " + filePath);
406+
return;
407+
}
408+
const script = 'p="$1"; t="${1}.tmp"; o="$2"; ' +
409+
'echo "$3" | base64 -d > "$t" && ' +
410+
'[ -s "$t" ] && ' +
411+
'mv -f "$t" "$p" && ' +
412+
'{ [ -z "$o" ] || [ "$o" = "$p" ] || rm -f "$o"; } ' +
413+
'|| { rm -f "$t"; exit 1; }';
414+
Quickshell.execDetached(["sh", "-c", script, "atomicWrite",
415+
filePath, oldFilePath || "", base64]);
416+
}
417+
389418
// Function to save pinned items to file
390419
function savePinnedFile() {
391420
const data = {
392421
items: root.pinnedItems
393422
};
394423
const json = JSON.stringify(data, null, 2);
395-
396-
// Use base64 encoding to safely pass JSON through shell
397-
// stringToBase64() produces valid base64 (A-Z, a-z, 0-9, +, /, =) - no shell metacharacters
398-
// File path is constant, not user-controlled
399424
const base64 = stringToBase64(json);
400425
const filePath = Quickshell.env("HOME") + "/.config/noctalia/plugins/clipper/pinned.json";
401426

402-
Quickshell.execDetached(["sh", "-c", `echo "${base64}" | base64 -d > "${filePath}"`]);
427+
atomicWriteBase64(filePath, base64);
403428
}
404429

405430
// Function to unpin item
@@ -481,10 +506,14 @@ Item {
481506

482507
const newFilename = getNoteFilename(updatedNote);
483508

484-
// If filename changed (title changed), delete old file
509+
// Track the stale filename so saveNoteCard / atomicWriteBase64 can delete
510+
// it atomically only AFTER the new file is successfully on disk. The
511+
// previous code fired rm and save in parallel via execDetached, which
512+
// could reorder so that rm landed after a failed save — wiping both
513+
// files at once. Never again.
514+
let oldFilePathToReplace = "";
485515
if (oldFilename !== newFilename && updates.title !== undefined) {
486-
const oldFilePath = root.noteCardsDir + "/" + oldFilename;
487-
Quickshell.execDetached(["rm", oldFilePath]);
516+
oldFilePathToReplace = root.noteCardsDir + "/" + oldFilename;
488517
}
489518

490519
// Immutable array update
@@ -497,8 +526,8 @@ Item {
497526
root.noteCards = newNotes;
498527
root.noteCardsRevision++;
499528

500-
// Save to file
501-
saveNoteCard(updatedNote);
529+
// Save to file (old filename is removed only on successful new save)
530+
saveNoteCard(updatedNote, oldFilePathToReplace);
502531
}
503532

504533
// Function to delete a note card
@@ -566,9 +595,9 @@ Item {
566595
const fileName = "notecard_" + timestamp + ".txt";
567596
const filePath = Quickshell.env("HOME") + "/Documents/" + fileName;
568597

569-
// Use base64 encoding to safely pass content through shell
598+
// Atomic write so a partial/killed export cannot leave a 0-byte .txt
570599
const base64 = stringToBase64(note.content || "");
571-
Quickshell.execDetached(["sh", "-c", `echo "${base64}" | base64 -d > "${filePath}"`]);
600+
atomicWriteBase64(filePath, base64);
572601

573602
// Store exported filename - append to list so all exports are tracked
574603
const existingExports = note.exportedFiles || [];
@@ -604,15 +633,24 @@ Item {
604633
return title + ".json";
605634
}
606635

607-
// Function to save individual notecard to file
608-
function saveNoteCard(note) {
636+
// Function to save individual notecard to file.
637+
// oldFilePath (optional) is the previous on-disk filename when the note
638+
// has been renamed — atomicWriteBase64 removes it only after the new file
639+
// is verified non-empty, so a failed save never wipes the old data.
640+
function saveNoteCard(note, oldFilePath) {
641+
if (!note || !note.id) {
642+
Logger.w("Clipper", "saveNoteCard: refusing to save invalid note");
643+
return;
644+
}
609645
const filename = getNoteFilename(note);
610646
const filePath = root.noteCardsDir + "/" + filename;
611647
const json = JSON.stringify(note, null, 2);
612-
613-
// Use base64 encoding to safely pass JSON through shell
648+
if (!json || json.length < 10) {
649+
Logger.w("Clipper", "saveNoteCard: refusing suspiciously small JSON for note " + note.id);
650+
return;
651+
}
614652
const base64 = stringToBase64(json);
615-
Quickshell.execDetached(["sh", "-c", `echo "${base64}" | base64 -d > "${filePath}"`]);
653+
atomicWriteBase64(filePath, base64, oldFilePath);
616654
}
617655

618656
// Function to save all note cards (saves each to individual file)
@@ -1270,6 +1308,14 @@ Item {
12701308
// Create notecards directory if it doesn't exist
12711309
Quickshell.execDetached(["mkdir", "-p", root.noteCardsDir]);
12721310

1311+
// Sweep any stale .tmp files left over from a prior interrupted atomic
1312+
// write (shell killed between tmp-write and rename). These are never
1313+
// meaningful data; leaving them around would confuse the `jq -s '*.json'`
1314+
// loader on next start.
1315+
Quickshell.execDetached(["sh", "-c",
1316+
'find "$1" -maxdepth 1 -name "*.json.tmp" -type f -delete 2>/dev/null',
1317+
"cleanTmp", root.noteCardsDir]);
1318+
12731319
// Force reload pinned items from file
12741320
pinnedFile.reload();
12751321

@@ -1297,8 +1343,6 @@ Item {
12971343
getSelectionForNoteSelectorProcess.terminate();
12981344
if (copyToClipboardProc.running)
12991345
copyToClipboardProc.terminate();
1300-
if (wlCopyProc.running)
1301-
wlCopyProc.terminate();
13021346
if (deleteItemProc.running)
13031347
deleteItemProc.terminate();
13041348
if (wipeProc.running)

clipper/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"id": "clipper",
33
"name": "Clipper",
4-
"version": "2.4.1",
4+
"version": "2.4.3",
55
"minNoctaliaVersion": "4.1.2",
66
"author": "blackbartblues",
77
"contributors": [

0 commit comments

Comments
 (0)