Persist notification images so history keeps avatars (#6804)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
edcbcb4691
commit
b2207c3357
@@ -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,49 @@ 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(
|
||||
/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*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'
|
||||
)
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user