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
@@ -186,8 +186,59 @@ function popupEntry(value, normalUrgency) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function popupFileName(entry) {
|
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 || {}
|
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) {
|
function serializePopup(entry, normalUrgency) {
|
||||||
@@ -306,6 +357,9 @@ if (typeof module !== "undefined") {
|
|||||||
historyRows: historyRows,
|
historyRows: historyRows,
|
||||||
popupEntry: popupEntry,
|
popupEntry: popupEntry,
|
||||||
popupFileName: popupFileName,
|
popupFileName: popupFileName,
|
||||||
|
imageStem: imageStem,
|
||||||
|
localImageFile: localImageFile,
|
||||||
|
persistablePopup: persistablePopup,
|
||||||
serializePopup: serializePopup,
|
serializePopup: serializePopup,
|
||||||
parsePopupFiles: parsePopupFiles,
|
parsePopupFiles: parsePopupFiles,
|
||||||
popupExpired: popupExpired,
|
popupExpired: popupExpired,
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ Item {
|
|||||||
// the newest historyLimit. This directory IS the history: `showHistory`
|
// the newest historyLimit. This directory IS the history: `showHistory`
|
||||||
// replays exactly what has been moved in here.
|
// replays exactly what has been moved in here.
|
||||||
readonly property string historyDir: popupStateDir + "history/"
|
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.
|
// Corner radius is shared with the menu and shell panels.
|
||||||
// It mirrors Hyprland's current decoration:rounding value.
|
// It mirrors Hyprland's current decoration:rounding value.
|
||||||
readonly property int cornerRadius: Style.cornerRadius
|
readonly property int cornerRadius: Style.cornerRadius
|
||||||
@@ -170,7 +174,10 @@ Item {
|
|||||||
// The toast never shows, so the only record a silenced notification
|
// The toast never shows, so the only record a silenced notification
|
||||||
// can leave is a history entry. Write it straight into history —
|
// can leave is a history entry. Write it straight into history —
|
||||||
// "what did I miss while silenced" is exactly what history is for.
|
// "what did I miss while silenced" is exactly what history is for.
|
||||||
if (!isEphemeral(notification)) writeHistoryFile(snapshot)
|
if (!isEphemeral(notification)) {
|
||||||
|
writeSilenced(notification, snapshot)
|
||||||
|
return
|
||||||
|
}
|
||||||
delete liveRefs[snapshot.originalId]
|
delete liveRefs[snapshot.originalId]
|
||||||
notification.tracked = false
|
notification.tracked = false
|
||||||
return
|
return
|
||||||
@@ -190,6 +197,38 @@ 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) {
|
||||||
|
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
|
// 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
|
// the notification in place, which is the only kind of update we ever hear
|
||||||
// about after the popup exists.
|
// about after the popup exists.
|
||||||
@@ -371,7 +410,7 @@ Item {
|
|||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: ensureDirsProc
|
id: ensureDirsProc
|
||||||
command: ["mkdir", "-p", service.stateDir, service.popupStateDir, service.historyDir]
|
command: ["mkdir", "-p", service.stateDir, service.popupStateDir, service.historyDir, service.imagesDir]
|
||||||
running: false
|
running: false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,16 +428,19 @@ Item {
|
|||||||
// match these rows against fresh notifications.
|
// match these rows against fresh notifications.
|
||||||
property var restoredPopups: ({})
|
property var restoredPopups: ({})
|
||||||
|
|
||||||
// Entries are either { command } for a file job or { read: true } for a
|
// Entries are either { command, done } for a file job or { read: true } for
|
||||||
// replay's directory read. Queueing the read rather than running it beside
|
// 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
|
// 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.
|
// 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
|
// Everything queued after it — a clear, an archive, a silenced write — waits
|
||||||
// for it, and no amount of later traffic can push it back.
|
// for it, and no amount of later traffic can push it back.
|
||||||
property var popupFileQueue: []
|
property var popupFileQueue: []
|
||||||
|
|
||||||
function enqueuePopupFileJob(command) {
|
// Done callback of the job popupFileProc is currently running.
|
||||||
popupFileQueue = popupFileQueue.concat([{ command: command }])
|
property var runningPopupFileJobDone: null
|
||||||
|
|
||||||
|
function enqueuePopupFileJob(command, done) {
|
||||||
|
popupFileQueue = popupFileQueue.concat([{ command: command, done: done || null }])
|
||||||
runNextPopupFileJob()
|
runNextPopupFileJob()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,31 +462,66 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
popupFileProc.command = job.command
|
popupFileProc.command = job.command
|
||||||
|
service.runningPopupFileJobDone = job.done || null
|
||||||
popupFileProc.running = true
|
popupFileProc.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: popupFileProc
|
id: popupFileProc
|
||||||
running: false
|
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. 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" +
|
||||||
|
" 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"
|
||||||
|
|
||||||
function persistPopupFile(snapshot) {
|
function persistPopupFile(snapshot) {
|
||||||
// The JSON travels as an argument, not through shell interpolation, so
|
// The JSON travels as an argument, not through shell interpolation, so
|
||||||
// summaries/bodies with quotes or backticks can't break the command. The
|
// summaries/bodies with quotes or backticks can't break the command. The
|
||||||
// mkdir guards notifications that arrive before ensureDirsProc has run.
|
// mkdir guards notifications that arrive before ensureDirsProc has run.
|
||||||
enqueuePopupFileJob(["bash", "-c",
|
// Copies run before the JSON referencing them, while the source exists.
|
||||||
"mkdir -p \"$1\" && printf '%s\\n' \"$2\" > \"$1/$3\"", "--",
|
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,
|
popupStateDir,
|
||||||
NotificationLogic.serializePopup(snapshot, NotificationUrgency.Normal),
|
imagesDir,
|
||||||
NotificationLogic.popupFileName(snapshot)])
|
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) {
|
function deletePopupFileFor(row) {
|
||||||
if (!row) return
|
if (!row) return
|
||||||
// History replays and the "no recent notifications" placeholder never
|
// History replays and the "no recent notifications" placeholder never
|
||||||
// had a file — rm -f on the computed path is a harmless no-op there.
|
// had a file — rm -f on the computed paths is a harmless no-op there.
|
||||||
enqueuePopupFileJob(["rm", "-f", popupStateDir + NotificationLogic.popupFileName(row)])
|
enqueuePopupFileJob(["bash", "-c",
|
||||||
|
"rm -f \"$1/$2.json\" \"$3/$2\"-*", "--",
|
||||||
|
popupStateDir, NotificationLogic.imageStem(row), imagesDir])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------- history
|
// ---------------------------------------------------- history
|
||||||
@@ -452,23 +529,26 @@ Item {
|
|||||||
// A popup that leaves the screen keeps its file — it just moves one level
|
// 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
|
// down, into historyDir. Trimming happens right there in the same shell
|
||||||
// job: the names sort numerically by their leading millisecond timestamp,
|
// job: the names sort numerically by their leading millisecond timestamp,
|
||||||
// so everything but the newest historyLimit files is the tail to drop.
|
// so everything but the newest historyLimit files is the tail to drop,
|
||||||
// $1 is historyDir and $2 the limit in both jobs below.
|
// image copies included. Callers set $hist, $limit and $imgs first.
|
||||||
readonly property string trimHistoryScript:
|
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) {
|
function archivePopupFileFor(row) {
|
||||||
if (!row) return
|
if (!row) return
|
||||||
// A history replay or the empty-history placeholder has no file to move;
|
// 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",
|
enqueuePopupFileJob(["bash", "-c",
|
||||||
"mkdir -p \"$1\" || exit 0\n" +
|
"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" +
|
"mv -f \"$4/$3\" \"$1/$3\" 2>/dev/null || exit 0\n" +
|
||||||
trimHistoryScript, "--",
|
trimHistoryScript, "--",
|
||||||
historyDir,
|
historyDir,
|
||||||
String(historyLimit),
|
String(historyLimit),
|
||||||
NotificationLogic.popupFileName(row),
|
NotificationLogic.popupFileName(row),
|
||||||
popupStateDir])
|
popupStateDir,
|
||||||
|
imagesDir])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record a notification that never made it to the screen (DND silenced it),
|
// Record a notification that never made it to the screen (DND silenced it),
|
||||||
@@ -481,21 +561,50 @@ Item {
|
|||||||
// notification here, and several can sit in the ten slots together — there
|
// 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
|
// is no id to recognize them by, and guessing from app and summary would
|
||||||
// merge genuinely separate messages.
|
// merge genuinely separate messages.
|
||||||
function writeHistoryFile(entry) {
|
function writeHistoryFile(entry, done) {
|
||||||
if (!entry) return
|
if (!entry) {
|
||||||
enqueuePopupFileJob(["bash", "-c",
|
if (done) done()
|
||||||
"mkdir -p \"$1\" || exit 0\n" +
|
return
|
||||||
"printf '%s\\n' \"$4\" > \"$1/$3\" || exit 0\n" +
|
}
|
||||||
|
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, "--",
|
trimHistoryScript, "--",
|
||||||
historyDir,
|
historyDir,
|
||||||
String(historyLimit),
|
String(historyLimit),
|
||||||
NotificationLogic.popupFileName(entry),
|
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() {
|
function clearHistory() {
|
||||||
enqueuePopupFileJob(["bash", "-c",
|
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" +
|
||||||
|
" [[ $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" +
|
||||||
|
"done", "--", popupStateDir, historyDir, imagesDir])
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
@@ -539,13 +648,15 @@ Item {
|
|||||||
|
|
||||||
// Copy the on-screen rows out of the model. The placeholder from an earlier
|
// 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
|
// 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() {
|
function liveRowsForReplay() {
|
||||||
var rows = []
|
var rows = []
|
||||||
for (var i = 0; i < popupModel.count; i++) {
|
for (var i = 0; i < popupModel.count; i++) {
|
||||||
var row = popupModel.get(i)
|
var row = popupModel.get(i)
|
||||||
if (!row || row.originalId < 0) continue
|
if (!row || row.originalId < 0) continue
|
||||||
rows.push({
|
rows.push(NotificationLogic.persistablePopup({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
originalId: row.originalId,
|
originalId: row.originalId,
|
||||||
app: row.app,
|
app: row.app,
|
||||||
@@ -557,7 +668,7 @@ Item {
|
|||||||
exec: row.exec || "",
|
exec: row.exec || "",
|
||||||
urgency: row.urgency,
|
urgency: row.urgency,
|
||||||
timestamp: row.timestamp
|
timestamp: row.timestamp
|
||||||
})
|
}, imagesDir).entry)
|
||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
@@ -734,6 +845,9 @@ Item {
|
|||||||
restorePopupsProc.command = ["bash", "-c",
|
restorePopupsProc.command = ["bash", "-c",
|
||||||
"awk 1 \"$1\"/*.json 2>/dev/null || true", "--", service.popupStateDir]
|
"awk 1 \"$1\"/*.json 2>/dev/null || true", "--", service.popupStateDir]
|
||||||
restorePopupsProc.running = true
|
restorePopupsProc.running = true
|
||||||
|
// Safe beside the restore read: it only re-persists entries whose
|
||||||
|
// JSON exists, exactly the images the sweep keeps.
|
||||||
|
service.sweepOrphanImages()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -263,6 +263,54 @@ assertEqual(
|
|||||||
'notifications preserve popup expire timeouts unlike history rows'
|
'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(
|
const popupFiles = notifications.parsePopupFiles(
|
||||||
[
|
[
|
||||||
notifications.serializePopup({ id: 1, originalId: 1, summary: 'old-generation', urgency: 2, timestamp: 100 }, 1),
|
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'
|
'notifications service archives by moving the popup file into the history dir'
|
||||||
)
|
)
|
||||||
assert(
|
assert(
|
||||||
/head -n \\"-\$2\\"/.test(serviceQml),
|
/head -n \\"-\$limit\\"/.test(serviceQml),
|
||||||
'notifications service trims history to the newest entries in the same job'
|
'notifications service trims history to the newest entries in the same job'
|
||||||
)
|
)
|
||||||
assert(
|
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'
|
'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(
|
assert(
|
||||||
/service\.replayCarryOver = liveRowsForReplay\(\)/.test(serviceQml),
|
/service\.replayCarryOver = liveRowsForReplay\(\)/.test(serviceQml),
|
||||||
'notifications service carries the toasts still on screen into the replay'
|
'notifications service carries the toasts still on screen into the replay'
|
||||||
|
|||||||
Reference in New Issue
Block a user