-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Persist notification images so history keeps avatars #6804
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,6 +33,10 @@ Item { | |
| // the newest historyLimit. This directory IS the history: `showHistory` | ||
| // replays exactly what has been moved in here. | ||
| readonly property string historyDir: popupStateDir + "history/" | ||
| // Copies of the avatars/images persisted entries reference — the sender's | ||
| // originals don't outlive the notification (see persistablePopup). Each | ||
| // copy lives and dies with the JSON file whose stem it carries. | ||
| readonly property string imagesDir: popupStateDir + "images/" | ||
| // Corner radius is shared with the menu and shell panels. | ||
| // It mirrors Hyprland's current decoration:rounding value. | ||
| readonly property int cornerRadius: Style.cornerRadius | ||
|
|
@@ -170,7 +174,14 @@ Item { | |
| // The toast never shows, so the only record a silenced notification | ||
| // can leave is a history entry. Write it straight into history — | ||
| // "what did I miss while silenced" is exactly what history is for. | ||
| if (!isEphemeral(notification)) writeHistoryFile(snapshot) | ||
| // Release only after the write: untracking tells the sender its | ||
| // notification closed, and Chromium deletes its avatar file on close. | ||
| if (!isEphemeral(notification)) { | ||
| writeHistoryFile(snapshot, function() { | ||
| service.releaseSilenced(notification, snapshot.originalId) | ||
| }) | ||
| return | ||
| } | ||
| delete liveRefs[snapshot.originalId] | ||
| notification.tracked = false | ||
| return | ||
|
|
@@ -190,6 +201,17 @@ Item { | |
| }) | ||
| } | ||
|
|
||
| // Let go of a DND-silenced notification once its history write has run. | ||
| // The id may have been reused and the object torn down meanwhile. | ||
| function releaseSilenced(notification, originalId) { | ||
| if (liveRefs[originalId] === notification) delete liveRefs[originalId] | ||
| try { | ||
| notification.tracked = false | ||
| } catch (e) { | ||
| // Object already destroyed by the server — nothing left to release. | ||
| } | ||
| } | ||
|
|
||
| // Everything the card draws. A change to any of these is a client updating | ||
| // the notification in place, which is the only kind of update we ever hear | ||
| // about after the popup exists. | ||
|
|
@@ -371,7 +393,7 @@ Item { | |
|
|
||
| Process { | ||
| id: ensureDirsProc | ||
| command: ["mkdir", "-p", service.stateDir, service.popupStateDir, service.historyDir] | ||
| command: ["mkdir", "-p", service.stateDir, service.popupStateDir, service.historyDir, service.imagesDir] | ||
| running: false | ||
| } | ||
|
|
||
|
|
@@ -389,16 +411,19 @@ Item { | |
| // match these rows against fresh notifications. | ||
| property var restoredPopups: ({}) | ||
|
|
||
| // Entries are either { command } for a file job or { read: true } for a | ||
| // replay's directory read. Queueing the read rather than running it beside | ||
| // Entries are either { command, done } for a file job or { read: true } for | ||
| // a replay's directory read. Queueing the read rather than running it beside | ||
| // the queue is what makes it a barrier: it takes its place in line, so the | ||
| // history it sees is the one that existed when the replay was asked for. | ||
| // Everything queued after it — a clear, an archive, a silenced write — waits | ||
| // for it, and no amount of later traffic can push it back. | ||
| property var popupFileQueue: [] | ||
|
|
||
| function enqueuePopupFileJob(command) { | ||
| popupFileQueue = popupFileQueue.concat([{ command: command }]) | ||
| // Done callback of the job popupFileProc is currently running. | ||
| property var runningPopupFileJobDone: null | ||
|
|
||
| function enqueuePopupFileJob(command, done) { | ||
| popupFileQueue = popupFileQueue.concat([{ command: command, done: done || null }]) | ||
| runNextPopupFileJob() | ||
| } | ||
|
|
||
|
|
@@ -420,55 +445,91 @@ Item { | |
| } | ||
|
|
||
| popupFileProc.command = job.command | ||
| service.runningPopupFileJobDone = job.done || null | ||
| popupFileProc.running = true | ||
| } | ||
|
|
||
| Process { | ||
| id: popupFileProc | ||
| running: false | ||
| onExited: service.runNextPopupFileJob() | ||
| onExited: { | ||
| var done = service.runningPopupFileJobDone | ||
| service.runningPopupFileJobDone = null | ||
| if (done) { | ||
| try { | ||
| done() | ||
| } catch (e) { | ||
| console.warn("notifications: file job callback failed:", e) | ||
| } | ||
| } | ||
| service.runNextPopupFileJob() | ||
| } | ||
| } | ||
|
|
||
| // Consumes the remaining args as from/to pairs. Only bounded regular files | ||
| // are copied: a sender pointing at a FIFO or device must not hang the | ||
| // queue or fill the state dir. | ||
| readonly property string copyImagesScript: | ||
| "while (( $# >= 2 )); do\n" + | ||
| " [[ -f $1 ]] && (( $(stat -c%s -- \"$1\" 2>/dev/null || echo 0) <= 5242880 )) && cp -f -- \"$1\" \"$2\" 2>/dev/null\n" + | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 07aa969. The copy no longer reopens the source with — 🤖 Claude, posting on behalf of @dhh |
||
| " shift 2\n" + | ||
| "done\n" | ||
|
|
||
| function persistPopupFile(snapshot) { | ||
| // The JSON travels as an argument, not through shell interpolation, so | ||
| // summaries/bodies with quotes or backticks can't break the command. The | ||
| // mkdir guards notifications that arrive before ensureDirsProc has run. | ||
| enqueuePopupFileJob(["bash", "-c", | ||
| "mkdir -p \"$1\" && printf '%s\\n' \"$2\" > \"$1/$3\"", "--", | ||
| // Copies run before the JSON referencing them, while the source exists. | ||
| var persistable = NotificationLogic.persistablePopup(snapshot, imagesDir) | ||
| var command = ["bash", "-c", | ||
| "mkdir -p \"$1\" \"$2\" || exit 0\n" + | ||
| "dir=\"$1\" json=\"$3\" name=\"$4\"\n" + | ||
| "shift 4\n" + | ||
| copyImagesScript + | ||
| "printf '%s\\n' \"$json\" > \"$dir/$name\"", "--", | ||
| popupStateDir, | ||
| NotificationLogic.serializePopup(snapshot, NotificationUrgency.Normal), | ||
| NotificationLogic.popupFileName(snapshot)]) | ||
| imagesDir, | ||
| NotificationLogic.serializePopup(persistable.entry, NotificationUrgency.Normal), | ||
| NotificationLogic.popupFileName(snapshot)] | ||
| for (var i = 0; i < persistable.copies.length; i++) | ||
| command.push(persistable.copies[i].from, persistable.copies[i].to) | ||
| enqueuePopupFileJob(command) | ||
| } | ||
|
|
||
| function deletePopupFileFor(row) { | ||
| if (!row) return | ||
| // History replays and the "no recent notifications" placeholder never | ||
| // had a file — rm -f on the computed path is a harmless no-op there. | ||
| enqueuePopupFileJob(["rm", "-f", popupStateDir + NotificationLogic.popupFileName(row)]) | ||
| // had a file — rm -f on the computed paths is a harmless no-op there. | ||
| enqueuePopupFileJob(["bash", "-c", | ||
| "rm -f \"$1/$2.json\" \"$3/$2\"-*", "--", | ||
| popupStateDir, NotificationLogic.imageStem(row), imagesDir]) | ||
| } | ||
|
|
||
| // ---------------------------------------------------- history | ||
| // | ||
| // A popup that leaves the screen keeps its file — it just moves one level | ||
| // down, into historyDir. Trimming happens right there in the same shell | ||
| // job: the names sort numerically by their leading millisecond timestamp, | ||
| // so everything but the newest historyLimit files is the tail to drop. | ||
| // $1 is historyDir and $2 the limit in both jobs below. | ||
| // so everything but the newest historyLimit files is the tail to drop, | ||
| // image copies included. Callers set $hist, $limit and $imgs first. | ||
| readonly property string trimHistoryScript: | ||
| "ls -1 \"$1\" 2>/dev/null | sort -n | head -n \"-$2\" | while IFS= read -r stale; do rm -f \"$1/$stale\"; done" | ||
| "ls -1 \"$hist\" 2>/dev/null | sort -n | head -n \"-$limit\" | while IFS= read -r stale; do rm -f \"$hist/$stale\" \"$imgs/${stale%.json}\"-*; done" | ||
|
|
||
| function archivePopupFileFor(row) { | ||
| if (!row) return | ||
| // A history replay or the empty-history placeholder has no file to move; | ||
| // the failed mv leaves the history untouched, trimming included. | ||
| // the failed mv leaves the history untouched, trimming included. Image | ||
| // copies stay put — live and archived entries share imagesDir. | ||
| enqueuePopupFileJob(["bash", "-c", | ||
| "mkdir -p \"$1\" || exit 0\n" + | ||
| "hist=\"$1\" limit=\"$2\" imgs=\"$5\"\n" + | ||
| "mv -f \"$4/$3\" \"$1/$3\" 2>/dev/null || exit 0\n" + | ||
| trimHistoryScript, "--", | ||
| historyDir, | ||
| String(historyLimit), | ||
| NotificationLogic.popupFileName(row), | ||
| popupStateDir]) | ||
| popupStateDir, | ||
| imagesDir]) | ||
| } | ||
|
|
||
| // Record a notification that never made it to the screen (DND silenced it), | ||
|
|
@@ -481,21 +542,49 @@ Item { | |
| // notification here, and several can sit in the ten slots together — there | ||
| // is no id to recognize them by, and guessing from app and summary would | ||
| // merge genuinely separate messages. | ||
| function writeHistoryFile(entry) { | ||
| if (!entry) return | ||
| enqueuePopupFileJob(["bash", "-c", | ||
| "mkdir -p \"$1\" || exit 0\n" + | ||
| "printf '%s\\n' \"$4\" > \"$1/$3\" || exit 0\n" + | ||
| function writeHistoryFile(entry, done) { | ||
| if (!entry) { | ||
| if (done) done() | ||
| return | ||
| } | ||
| var persistable = NotificationLogic.persistablePopup(entry, imagesDir) | ||
| var command = ["bash", "-c", | ||
| "mkdir -p \"$1\" \"$5\" || exit 0\n" + | ||
| "hist=\"$1\" limit=\"$2\" name=\"$3\" json=\"$4\" imgs=\"$5\"\n" + | ||
| "shift 5\n" + | ||
| copyImagesScript + | ||
| "printf '%s\\n' \"$json\" > \"$hist/$name\" || exit 0\n" + | ||
| trimHistoryScript, "--", | ||
| historyDir, | ||
| String(historyLimit), | ||
| NotificationLogic.popupFileName(entry), | ||
| NotificationLogic.serializePopup(entry, NotificationUrgency.Normal)]) | ||
| NotificationLogic.serializePopup(persistable.entry, NotificationUrgency.Normal), | ||
| imagesDir] | ||
| for (var i = 0; i < persistable.copies.length; i++) | ||
| command.push(persistable.copies[i].from, persistable.copies[i].to) | ||
| enqueuePopupFileJob(command, done) | ||
| } | ||
|
|
||
| function clearHistory() { | ||
| enqueuePopupFileJob(["bash", "-c", | ||
| "rm -f \"$1\"/*.json", "--", historyDir]) | ||
| "for f in \"$1\"/*.json; do\n" + | ||
| " [[ -e $f ]] || continue\n" + | ||
| " stale=\"${f##*/}\"\n" + | ||
| " rm -f \"$f\" \"$2/${stale%.json}\"-*\n" + | ||
| "done", "--", historyDir, imagesDir]) | ||
| } | ||
|
|
||
| // A restart can kill a queued job between its cp and its JSON write, | ||
| // leaving copies no JSON-derived cleanup can name. Swept at startup, | ||
| // through the queue so in-flight copies aren't mistaken for orphans. | ||
| function sweepOrphanImages() { | ||
| enqueuePopupFileJob(["bash", "-c", | ||
| "for img in \"$3\"/*; do\n" + | ||
| " [[ -e $img ]] || continue\n" + | ||
| " stem=\"${img##*/}\"\n" + | ||
| " stem=\"${stem%-*}\"\n" + | ||
| " [[ -e $1/$stem.json || -e $2/$stem.json ]] || rm -f \"$img\"\n" + | ||
| "done", "--", popupStateDir, historyDir, imagesDir]) | ||
| } | ||
|
|
||
| Process { | ||
|
|
@@ -539,13 +628,15 @@ Item { | |
|
|
||
| // Copy the on-screen rows out of the model. The placeholder from an earlier | ||
| // empty replay carries originalId -1 and is not a notification, so it is | ||
| // left behind rather than replayed as one. | ||
| // left behind rather than replayed as one. The replay dismisses these | ||
| // notifications, and senders delete their images on close — so the carried | ||
| // rows point at the persisted copies, like the archived files they join. | ||
| function liveRowsForReplay() { | ||
| var rows = [] | ||
| for (var i = 0; i < popupModel.count; i++) { | ||
| var row = popupModel.get(i) | ||
| if (!row || row.originalId < 0) continue | ||
| rows.push({ | ||
| rows.push(NotificationLogic.persistablePopup({ | ||
| id: row.id, | ||
| originalId: row.originalId, | ||
| app: row.app, | ||
|
|
@@ -557,7 +648,7 @@ Item { | |
| exec: row.exec || "", | ||
| urgency: row.urgency, | ||
| timestamp: row.timestamp | ||
| }) | ||
| }, imagesDir).entry) | ||
| } | ||
| return rows | ||
| } | ||
|
|
@@ -734,6 +825,9 @@ Item { | |
| restorePopupsProc.command = ["bash", "-c", | ||
| "awk 1 \"$1\"/*.json 2>/dev/null || true", "--", service.popupStateDir] | ||
| restorePopupsProc.running = true | ||
| // Safe beside the restore read: it only re-persists entries whose | ||
| // JSON exists, exactly the images the sweep keeps. | ||
| service.sweepOrphanImages() | ||
| }) | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 07aa969. The DND path now goes through
writeSilenced, which re-snapshots the tracked object when each write completes and writes again (reusing the original file identity) until the content is stable — only then doesreleaseSilenceduntrack. An in-placereplaces_idupdate that lands while a write is queued is therefore persisted rather than dropped, and the release never closes the notification on stale content.— 🤖 Claude, posting on behalf of @dhh