Persist notification images so history keeps avatars - #6804
Conversation
There was a problem hiding this comment.
Pull request overview
Persists notification images so restored history retains avatars and icons.
Changes:
- Copies file-backed images into notification state storage.
- Cleans image copies alongside their JSON records.
- Delays releasing DND notifications until persistence completes.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
NotificationLogic.js |
Builds persisted image paths and copy operations. |
Service.qml |
Integrates image lifecycle management and DND persistence. |
notifications-test.sh |
Adds persistence and cleanup coverage. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if (!isEphemeral(notification)) { | ||
| writeHistoryFile(snapshot, function() { | ||
| service.releaseSilenced(notification, snapshot.originalId) | ||
| }) | ||
| return |
There was a problem hiding this comment.
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 does releaseSilenced untrack. An in-place replaces_id update 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
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 <noreply@anthropic.com>
9bed955 to
472777d
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
shell/plugins/notifications/Service.qml:183
- Holding this object tracked changes
replaces_idbehavior while this queued write is pending: Quickshell updates the existing notification object instead of emitting anotheronNotification, but this DND branch returns without connectingwatchForUpdates. An update arriving before the callback is therefore discarded, andreleaseSilencedthen closes the updated object with only the stale first snapshot in history. Track DND updates and defer release until the latest snapshot has been persisted, or retain the old immediate-untracking semantics while preserving the image separately; add a regression test with an update delayed behind another file job.
if (!isEphemeral(notification)) {
writeHistoryFile(snapshot, function() {
service.releaseSilenced(notification, snapshot.originalId)
})
return
| // 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" + |
There was a problem hiding this comment.
Fixed in 07aa969. The copy no longer reopens the source with cp after the checks: it reads through timeout 5 head -c <limit+1> into a .tmp file in the images dir, validates the temp file's final size, and renames it into place atomically. A source that grows or is swapped for a FIFO mid-copy can now at most stall its own job for 5 seconds and never publishes an oversized or partial copy; the startup sweep clears any .tmp a killed job leaves behind.
— 🤖 Claude, posting on behalf of @dhh
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 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (2)
shell/plugins/notifications/Service.qml:515
- This queues the only read of the sender-owned image but does not tie notification closure to completion of that job. If another queue job is running (each image read may take up to 5 seconds), the toast can be dismissed or expire first;
removePopupimmediately callsref.dismiss(), Chromium removes the scoped file, and this queued job later persists a missing copy. Track initial persistence completion per popup and defer closing the live notification until its image-copy job has completed.
for (var i = 0; i < persistable.copies.length; i++)
command.push(persistable.copies[i].from, persistable.copies[i].to)
enqueuePopupFileJob(command)
shell/plugins/notifications/NotificationLogic.js:238
- The persisted role is redirected before the copy outcome is known, while
copyImagesScriptdeliberately treats missing, timed-out, unreadable, and >5 MiB sources as non-fatal. The job still writes this JSON, so these cases leave a non-emptyfile://...URL with no file; forimage,NotificationCard.smallIconSourcethen does not fall back toappIcon. Only redirect a role after its temp file was successfully renamed, and persist an empty role on copy failure.
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] = ""
Notification history replayed entries without their avatars. The persisted JSON stored
image/appIconas 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/tmpdir deleted the moment the notification closes, and rawimage-datahints surface as in-processimage://URLs that die with the server object. Replay then found dead references and hid the icon.What changed
~/.local/state/omarchy/notifications/images/, named by the entry's file stem (<timestamp>-<id>-appIcon/-image), and the persisted JSON references the copy. The copy runs in the same serialized queue job, before the JSON that references it. Deadimage://URLs are blanked so the card falls back to the app icon.cpand its JSON write.— 🤖 Claude, posting on behalf of @dhh