Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion shell/plugins/notifications/NotificationLogic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
170 changes: 142 additions & 28 deletions shell/plugins/notifications/Service.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -170,7 +174,10 @@ 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)
if (!isEphemeral(notification)) {
writeSilenced(notification, snapshot)
return
Comment on lines +177 to +179

Copy link
Copy Markdown
Member Author

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 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

}
delete liveRefs[snapshot.originalId]
notification.tracked = false
return
Expand All @@ -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
// the notification in place, which is the only kind of update we ever hear
// about after the popup exists.
Expand Down Expand Up @@ -371,7 +410,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
}

Expand All @@ -389,16 +428,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()
}

Expand All @@ -420,55 +462,93 @@ 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. 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) {
// 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),
Expand All @@ -481,21 +561,50 @@ 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" +
" [[ $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 {
Expand Down Expand Up @@ -539,13 +648,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,
Expand All @@ -557,7 +668,7 @@ Item {
exec: row.exec || "",
urgency: row.urgency,
timestamp: row.timestamp
})
}, imagesDir).entry)
}
return rows
}
Expand Down Expand Up @@ -734,6 +845,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()
})
}

Expand Down
Loading