From 472777dee04f2204013eede23b4d54e442ca88bb Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 07:09:10 -0700 Subject: [PATCH 1/2] Persist notification images so history keeps avatars Persisted popup and history entries stored image/appIcon as URLs into resources that die with the live notification: Chromium-family senders (every Omarchy web app, WhatsApp included) pass avatars as files in a scoped /tmp dir deleted when the notification closes, and raw image-data hints surface as in-process image:// URLs that die with the server object. Replaying history then found dead references and hid the icon. Copy file-backed images into the notification state dir when persisting, keyed by the entry's file stem, and reference the copies from the JSON. Blank dead image:// URLs so the card falls back to the app icon. The copies die with their JSON: superseded-popup deletes, history trims and clears remove them, and a startup sweep collects copies orphaned by a restart killing a queued job mid-write. Hold DND-silenced notifications open until their history write has run, since untracking tells the sender to delete its avatar file, and carry replayed on-screen rows over via their persisted copies, since the replay dismisses their live notifications first. Co-Authored-By: Claude Fable 5 --- .../notifications/NotificationLogic.js | 56 ++++++- shell/plugins/notifications/Service.qml | 150 ++++++++++++++---- test/shell.d/notifications-test.sh | 84 +++++++++- 3 files changed, 259 insertions(+), 31 deletions(-) diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index 7ea4df9ef9..1d2ebd4359 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -186,8 +186,59 @@ function popupEntry(value, normalUrgency) { } function popupFileName(entry) { + return imageStem(entry) + ".json" +} + +// ---------------------------------------------------- persisted images +// +// A notification's images only exist while it is live: Chromium-family +// senders (all Omarchy web apps) delete their scoped /tmp files on close, +// and image-data hints surface as in-process image:// URLs that die with +// the server object. Persisted entries therefore reference their own +// copies, named by the entry's file stem so cleanup can find them from +// the JSON file name alone. + +var PERSISTED_IMAGE_ROLES = ["appIcon", "image"] + +function imageStem(entry) { var e = entry || {} - return String(e.timestamp || 0) + "-" + String(e.originalId || 0) + ".json" + return String(e.timestamp || 0) + "-" + String(e.originalId || 0) +} + +// The filesystem path behind a file-backed image value, or "" for anything +// a copy can't capture: themed icon names, in-process image:// URLs, empty. +function localImageFile(value) { + var s = String(value || "") + if (s.indexOf("file://") === 0) { + s = s.slice(7) + try { s = decodeURIComponent(s) } catch (e) {} + } + return s.charAt(0) === "/" ? s : "" +} + +// The entry as it should hit the disk, plus the copies that make it true. +// File-backed images redirect to their copy under imagesDir; dead image:// +// URLs drop to "" (the card falls back to the app icon). Already-redirected +// values map onto themselves and produce no copy, keeping restores no-ops. +function persistablePopup(entry, imagesDir) { + var e = entry || {} + var out = {} + for (var key in e) out[key] = e[key] + var copies = [] + for (var i = 0; i < PERSISTED_IMAGE_ROLES.length; i++) { + var role = PERSISTED_IMAGE_ROLES[i] + var value = String(out[role] || "") + if (!value) continue + var source = localImageFile(value) + if (source) { + var copy = String(imagesDir || "") + imageStem(e) + "-" + role + if (source !== copy) copies.push({ from: source, to: copy }) + out[role] = "file://" + copy + } else if (value.indexOf("image://") === 0) { + out[role] = "" + } + } + return { entry: out, copies: copies } } function serializePopup(entry, normalUrgency) { @@ -306,6 +357,9 @@ if (typeof module !== "undefined") { historyRows: historyRows, popupEntry: popupEntry, popupFileName: popupFileName, + imageStem: imageStem, + localImageFile: localImageFile, + persistablePopup: persistablePopup, serializePopup: serializePopup, parsePopupFiles: parsePopupFiles, popupExpired: popupExpired, diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index 9fd00661d7..9ba74f93a6 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -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,31 +445,64 @@ 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" + + " 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 @@ -452,23 +510,26 @@ Item { // 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() }) } diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 90a6c1868d..e8da62489a 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -263,6 +263,54 @@ assertEqual( 'notifications preserve popup expire timeouts unlike history rows' ) +// Persisted entries must not reference images another process owns: Chromium +// web apps (WhatsApp avatars included) delete their scoped /tmp files when +// the notification closes, and image:// URLs die with the live object. +assertEqual( + notifications.localImageFile('file:///tmp/scoped_dir/logo%20a.png'), + '/tmp/scoped_dir/logo a.png', + 'notifications resolve file URLs to copyable paths' +) +assertEqual(notifications.localImageFile('/tmp/avatar.png'), '/tmp/avatar.png', 'notifications treat absolute paths as copyable') +assertEqual(notifications.localImageFile('mail'), '', 'notifications leave themed icon names uncopied') +assertEqual(notifications.localImageFile('image://notifs/1'), '', 'notifications cannot copy in-process image URLs') + +const persistable = notifications.persistablePopup( + { id: 9, originalId: 9, timestamp: 2000, appIcon: 'file:///tmp/scoped/logo.png', image: 'image://notifs/9', summary: 'Hi' }, + '/state/images/' +) +assertDeepEqual( + persistable.copies, + [{ from: '/tmp/scoped/logo.png', to: '/state/images/2000-9-appIcon' }], + 'notifications copy file-backed images into the state dir when persisting' +) +assertEqual( + persistable.entry.appIcon, + 'file:///state/images/2000-9-appIcon', + 'notifications persist the image copy instead of the sender-owned original' +) +assertEqual(persistable.entry.image, '', 'notifications drop dead in-process image URLs from persisted entries') +assertEqual(persistable.entry.summary, 'Hi', 'notifications leave the rest of the persisted entry untouched') + +const repersisted = notifications.persistablePopup(persistable.entry, '/state/images/') +assertDeepEqual(repersisted.copies, [], 'notifications do not re-copy an entry already pointing at its copies') +assertEqual( + repersisted.entry.appIcon, + 'file:///state/images/2000-9-appIcon', + 'notifications keep a restored entry pointing at its existing copy' +) + +assertEqual( + notifications.persistablePopup({ id: 9, originalId: 9, timestamp: 2000, appIcon: 'mail', image: '' }, '/state/images/').copies.length, + 0, + 'notifications leave themed icons alone when persisting' +) +assertEqual( + notifications.imageStem({ originalId: 9, timestamp: 2000 }) + '.json', + notifications.popupFileName({ originalId: 9, timestamp: 2000 }), + 'notifications name image copies by the stem of the entry file they belong to' +) + const popupFiles = notifications.parsePopupFiles( [ notifications.serializePopup({ id: 1, originalId: 1, summary: 'old-generation', urgency: 2, timestamp: 100 }, 1), @@ -363,13 +411,45 @@ assert( 'notifications service archives by moving the popup file into the history dir' ) assert( - /head -n \\"-\$2\\"/.test(serviceQml), + /head -n \\"-\$limit\\"/.test(serviceQml), 'notifications service trims history to the newest entries in the same job' ) assert( - /if \(!isEphemeral\(notification\)\) writeHistoryFile\(snapshot\)/.test(serviceQml), + /\\"\$imgs\/\$\{stale%\.json\}\\"-\*/.test(serviceQml), + 'notifications service drops a trimmed history entry\'s image copies with it' +) +assert( + /readonly property string imagesDir: popupStateDir \+ "images\/"/.test(serviceQml), + 'notifications service keeps image copies beside the popup and history files' +) +assert( + /copyImagesScript \+\n\s*"printf/.test(serviceQml), + 'notifications service copies images before writing the JSON that references them' +) +assert( + /\[\[ -f \$1 \]\] && \(\( \$\(stat -c%s -- \\"\$1\\" 2>\/dev\/null \|\| echo 0\) <= 5242880 \)\) && cp -f/.test(serviceQml), + 'notifications service only copies bounded regular files' +) +assert( + /rm -f \\"\$1\/\$2\.json\\" \\"\$3\/\$2\\"-\*/.test(serviceQml), + 'notifications service deletes a superseded popup\'s image copies with its file' +) +assert( + /if \(!isEphemeral\(notification\)\) \{\s*\n\s*writeHistoryFile\(snapshot, function\(\) \{\s*\n\s*service\.releaseSilenced\(notification, snapshot\.originalId\)/.test(serviceQml), 'notifications service records DND-silenced notifications straight into history' ) +assert( + /function releaseSilenced\(notification, originalId\)[\s\S]{0,300}?notification\.tracked = false/.test(serviceQml), + 'notifications service holds a silenced notification until its history write has run' +) +assert( + /rows\.push\(NotificationLogic\.persistablePopup\(\{[\s\S]{0,400}?\}, imagesDir\)\.entry\)/.test(serviceQml), + 'notifications service replays carried-over toasts from their persisted image copies' +) +assert( + /function sweepOrphanImages\(\)[\s\S]{0,400}?\|\| rm -f \\"\$img\\"/.test(serviceQml), + 'notifications service sweeps image copies whose JSON never landed' +) assert( /service\.replayCarryOver = liveRowsForReplay\(\)/.test(serviceQml), 'notifications service carries the toasts still on screen into the replay' From 07aa96918647ab6c73e5ce7aa06383a20027c310 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 08:39:29 -0700 Subject: [PATCH 2/2] Coalesce silenced updates and bound image copies through temp files A replaces_id update lands on a held DND notification without a second onNotification, so releasing after the first write could persist a stale snapshot. Re-snapshot when the write completes and write again until the content is stable, reusing the original file identity. The image copy reopened the sender-controlled path after checking it, so a file growing or becoming a FIFO mid-copy defeated the size bound. Read through head -c under a timeout into a temp file, validate its size, and rename it into place; the startup sweep clears temp files a killed job leaves behind. Co-Authored-By: Claude Fable 5 --- shell/plugins/notifications/Service.qml | 38 +++++++++++++++++++------ test/shell.d/notifications-test.sh | 10 +++++-- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index 9ba74f93a6..88ad054472 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -174,12 +174,8 @@ 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. - // 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) - }) + writeSilenced(notification, snapshot) return } delete liveRefs[snapshot.originalId] @@ -201,6 +197,27 @@ Item { }) } + // Persist a silenced notification, held tracked until its content is + // stable: untracking tells the sender its notification closed (Chromium + // then deletes its avatar file), and a replaces_id update lands on this + // object without a second onNotification — releasing on a stale snapshot + // would drop it. Each catch-up write reuses the original file identity. + function writeSilenced(notification, written) { + writeHistoryFile(written, function() { + var updated = null + try { + updated = NotificationLogic.replacementSnapshot(notification, written.originalId, written.timestamp) + } catch (e) { + // Torn down by the server while the write was queued. + } + if (updated && NotificationLogic.popupRowChanged(written, updated)) { + service.writeSilenced(notification, updated) + return + } + service.releaseSilenced(notification, written.originalId) + }) + } + // 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) { @@ -466,12 +483,14 @@ Item { } } - // 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. + // Consumes the remaining args as from/to pairs. Bounded read into a temp + // file, validated, then renamed into place: the source path is + // sender-controlled and may grow, block, or become a FIFO mid-copy, and + // must neither hang the serialized queue nor 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" + + " if [[ -f $1 ]] && timeout 5 head -c 5242881 -- \"$1\" > \"$2.tmp\" 2>/dev/null &&\n" + + " (( $(stat -c%s -- \"$2.tmp\") <= 5242880 )); then mv -f -- \"$2.tmp\" \"$2\"; else rm -f -- \"$2.tmp\"; fi\n" + " shift 2\n" + "done\n" @@ -581,6 +600,7 @@ Item { enqueuePopupFileJob(["bash", "-c", "for img in \"$3\"/*; do\n" + " [[ -e $img ]] || continue\n" + + " [[ $img == *.tmp ]] && { rm -f -- \"$img\"; continue; }\n" + " stem=\"${img##*/}\"\n" + " stem=\"${stem%-*}\"\n" + " [[ -e $1/$stem.json || -e $2/$stem.json ]] || rm -f \"$img\"\n" + diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index e8da62489a..49a4bcd2a4 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -427,21 +427,25 @@ assert( 'notifications service copies images before writing the JSON that references them' ) assert( - /\[\[ -f \$1 \]\] && \(\( \$\(stat -c%s -- \\"\$1\\" 2>\/dev\/null \|\| echo 0\) <= 5242880 \)\) && cp -f/.test(serviceQml), - 'notifications service only copies bounded regular files' + /timeout 5 head -c 5242881 -- \\"\$1\\" > \\"\$2\.tmp\\"[\s\S]{0,120}?mv -f -- \\"\$2\.tmp\\" \\"\$2\\"/.test(serviceQml), + 'notifications service bounds image copies through a validated temp file' ) assert( /rm -f \\"\$1\/\$2\.json\\" \\"\$3\/\$2\\"-\*/.test(serviceQml), 'notifications service deletes a superseded popup\'s image copies with its file' ) assert( - /if \(!isEphemeral\(notification\)\) \{\s*\n\s*writeHistoryFile\(snapshot, function\(\) \{\s*\n\s*service\.releaseSilenced\(notification, snapshot\.originalId\)/.test(serviceQml), + /if \(!isEphemeral\(notification\)\) \{\s*\n\s*writeSilenced\(notification, snapshot\)/.test(serviceQml), 'notifications service records DND-silenced notifications straight into history' ) assert( /function releaseSilenced\(notification, originalId\)[\s\S]{0,300}?notification\.tracked = false/.test(serviceQml), 'notifications service holds a silenced notification until its history write has run' ) +assert( + /if \(updated && NotificationLogic\.popupRowChanged\(written, updated\)\) \{\s*\n\s*service\.writeSilenced\(notification, updated\)/.test(serviceQml), + 'notifications service re-persists a silenced notification updated while its write was queued' +) assert( /rows\.push\(NotificationLogic\.persistablePopup\(\{[\s\S]{0,400}?\}, imagesDir\)\.entry\)/.test(serviceQml), 'notifications service replays carried-over toasts from their persisted image copies'