From cf4404b5f19ef2245b6726162ab41edb80853dd9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 12 Aug 2026 00:01:35 -0700 Subject: [PATCH] Make notification history the last ten notifications on disk History was a pair of in-memory lists mirrored into notifications.json, split into "pending" and "past" by a seen/unseen distinction no surface exposed, capped at 100, deduped by an id that repeats across server generations, and pruned by a 15-minute TTL. Replaying it showed five rows drawn from whichever list happened to hold them. Every toast already writes a file under ~/.local/state/omarchy/notifications so it can survive a shell restart. That file is now the history record: when the popup leaves the screen it moves into notifications/history instead of being deleted, the newest ten are kept, and showHistory replays exactly what is in there, including the toasts still on screen when it is asked for. A notification DND silenced is written straight into the same directory, since a toast that never showed is the one worth looking back at. That leaves the models, notifications.json history payload, past pruning, and the /tmp image cache that existed to keep century-old history thumbnails alive with nothing to do, so they go. Co-Authored-By: Claude Opus 5 (1M context) --- migrations/1786517850.sh | 10 + .../notifications/NotificationLogic.js | 147 ++-- shell/plugins/notifications/Service.qml | 707 ++++++------------ test/shell.d/notifications-test.sh | 142 ++-- 4 files changed, 365 insertions(+), 641 deletions(-) create mode 100644 migrations/1786517850.sh diff --git a/migrations/1786517850.sh b/migrations/1786517850.sh new file mode 100644 index 0000000000..039c88d53b --- /dev/null +++ b/migrations/1786517850.sh @@ -0,0 +1,10 @@ +echo "Drop the retired notification image cache" + +# Notification history used to be a pair of long-lived lists in +# notifications.json, and thumbnails for it were copied out of /tmp into this +# cache so they would outlive the screenshot they came from. History is now the +# last ten notification files under ~/.local/state/omarchy/notifications/history, +# which reference their image where it already lives, so nothing writes or reads +# this directory anymore. + +rm -rf "$HOME/.cache/omarchy/notification-images" diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index 6f3968b4e8..128369de6f 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -110,114 +110,34 @@ function historyEntry(value, normalUrgency) { } } -function dedupeByOriginalId(rows) { - var values = Array.isArray(rows) ? rows : [] - var keep = {} - for (var i = 0; i < values.length; i++) { - var row = values[i] - if (!row) continue - var key = row.originalId - if (key === undefined || key === null) key = "_" + i - var prior = keep[key] - if (!prior || (row.timestamp || 0) >= (prior.timestamp || 0)) keep[key] = row - } - - var out = [] - for (var id in keep) out.push(keep[id]) - out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) }) - return out -} - -function parseHistory(raw, normalUrgency, historyCap) { +// notifications.json holds nothing but the last-set DND preference now that +// history is a directory of files. Older versions kept `pending`/`past` +// (and, older still, `entries`) arrays in there; their presence is reported +// so the service can rewrite the file without the dead payload. +function parseSettings(raw) { var text = String(raw || "").trim() - var cap = historyCap === undefined || historyCap === null ? 100 : Number(historyCap) - if (isNaN(cap)) cap = 100 - cap = Math.max(0, cap) - if (!text) return { empty: true, error: false, dnd: null, pending: [], past: [], hadDuplicates: false } + if (!text) return { error: false, dnd: null, legacy: false } try { var parsed = JSON.parse(text) - var pendingRaw = (parsed && Array.isArray(parsed.pending)) ? parsed.pending : [] - var pastRaw = (parsed && Array.isArray(parsed.past)) ? parsed.past : [] - if (parsed && Array.isArray(parsed.entries)) pastRaw = pastRaw.concat(parsed.entries) - - var pendingDeduped = dedupeByOriginalId(pendingRaw) - var pastDeduped = dedupeByOriginalId(pastRaw) - return { - empty: false, error: false, dnd: parsed && typeof parsed.dnd === "boolean" ? parsed.dnd : null, - pending: pendingDeduped.slice(0, cap).map(function(entry) { return historyEntry(entry, normalUrgency) }), - past: pastDeduped.slice(0, cap).map(function(entry) { return historyEntry(entry, normalUrgency) }), - hadDuplicates: pendingDeduped.length !== pendingRaw.length || pastDeduped.length !== pastRaw.length + legacy: !!(parsed && (parsed.pending || parsed.past || parsed.entries)) } } catch (e) { - return { empty: false, error: true, errorMessage: String(e), dnd: null, pending: [], past: [], hadDuplicates: false } + return { error: true, errorMessage: String(e), dnd: null, legacy: false } } } -function recentHistoryRows(pending, past, limit, normalUrgency) { - var max = limit === undefined || limit === null ? 5 : Number(limit) - if (isNaN(max)) max = 5 - max = Math.max(0, max) - - var values = [] - function collect(rows) { - var source = Array.isArray(rows) ? rows : [] - for (var i = 0; i < source.length; i++) { - if (source[i]) values.push(source[i]) - } - } - collect(pending) - collect(past) - - var keep = {} - for (var j = 0; j < values.length; j++) { - var row = values[j] - var key = row.originalId - if (key === undefined || key === null) key = row.id - if (key === undefined || key === null) key = "_" + j - var prior = keep[key] - if (!prior || (row.timestamp || 0) >= (prior.timestamp || 0)) keep[key] = row - } - - var out = [] - for (var id in keep) out.push(historyEntry(keep[id], normalUrgency)) - out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) }) - return out.slice(0, max) -} - -function dumpRows(rows) { - var values = Array.isArray(rows) ? rows : [] - var out = [] - for (var i = 0; i < values.length; i++) { - var r = values[i] - if (!r) continue - out.push({ - id: r.id, - originalId: r.originalId, - app: r.app, - appIcon: r.appIcon, - summary: r.summary, - body: r.body, - image: r.image, - glyph: r.glyph || "", - exec: r.exec || "", - urgency: r.urgency, - timestamp: r.timestamp - }) - } - return out -} - // ---------------------------------------------------- popup persistence // // Each on-screen popup is mirrored to its own file under // ~/.local/state/omarchy/notifications/ so toasts survive shell restarts // (e.g. the restart `omarchy-update` performs). The file exists exactly as // long as the popup is on screen: it is written when the toast appears and -// deleted when the toast expires, is dismissed, or its action is invoked. +// moved into the history/ subdirectory when the toast expires, is dismissed, +// or its action is invoked. History is those moved files, newest last-10. function popupEntry(value, normalUrgency) { var entry = historyEntry(value, normalUrgency) @@ -300,13 +220,37 @@ function popupPlacement(barPosition, barClearance, gapsOut) { } } -function imageExtension(srcPath) { - var lower = String(srcPath || "").toLowerCase() - var dot = lower.lastIndexOf(".") - if (dot < 0) return "png" - var ext = lower.substring(dot + 1) - if (ext.length === 0 || ext.length > 5) return "png" - return ext +// The archived files are the history. They are read back exactly like the +// live popup files, then normalized into history rows: replaying a toast +// must not inherit the original's expire timeout or restore deadline, so it +// gets the standard on-screen lifetime for its urgency instead. +// +// liveRows are the toasts still on screen when the replay was asked for. +// They belong in it — they're the newest notifications there are — but the +// directory read races their archival, so they're carried across by hand and +// keyed by file name (timestamp + id) to drop the copy the read already saw. +function historyRows(raw, liveRows, normalUrgency, limit) { + var max = limit === undefined || limit === null ? 10 : Number(limit) + if (isNaN(max)) max = 10 + max = Math.max(0, max) + + var out = [] + var seen = {} + function collect(rows) { + for (var i = 0; i < rows.length; i++) { + var entry = rows[i] + if (!entry) continue + var key = popupFileName(entry) + if (seen[key]) continue + seen[key] = true + out.push(historyEntry(entry, normalUrgency)) + } + } + + collect(Array.isArray(liveRows) ? liveRows : []) + collect(parsePopupFiles(raw, normalUrgency)) + out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) }) + return out.slice(0, max) } if (typeof module !== "undefined") { @@ -322,16 +266,13 @@ if (typeof module !== "undefined") { shouldRenderCompactGlyph: shouldRenderCompactGlyph, snapshotOf: snapshotOf, historyEntry: historyEntry, - dedupeByOriginalId: dedupeByOriginalId, - parseHistory: parseHistory, - recentHistoryRows: recentHistoryRows, - dumpRows: dumpRows, + parseSettings: parseSettings, + historyRows: historyRows, popupEntry: popupEntry, popupFileName: popupFileName, serializePopup: serializePopup, parsePopupFiles: parsePopupFiles, popupExpired: popupExpired, - popupPlacement: popupPlacement, - imageExtension: imageExtension + popupPlacement: popupPlacement } } diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index e5cb90b53c..0e81055258 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -20,19 +20,19 @@ Item { property string omarchyPath: Quickshell.env("OMARCHY_PATH") readonly property string home: Quickshell.env("HOME") // History + DND live under XDG_STATE_HOME: they're persistent user state - // (history of received notifications, last-set DND preference), not + // (the notifications received, the last-set DND preference), not // regeneratable cache that a `rm -rf ~/.cache` should wipe. readonly property string stateDir: home + "/.local/state/omarchy/" - readonly property string historyPath: stateDir + "notifications.json" + readonly property string settingsPath: stateDir + "notifications.json" // One file per on-screen popup, so live toasts survive shell restarts. // A file exists exactly as long as its popup is showing: written when the - // toast appears, deleted when it expires, is dismissed, or is acted upon. + // toast appears, moved into historyDir when it expires, is dismissed, or is + // acted upon. readonly property string popupStateDir: stateDir + "notifications/" - // Thumbnails copied from /tmp screenshots are genuinely disposable — if - // they vanish the row just renders without an image — so they stay in - // ~/.cache where regeneratable artifacts belong. - readonly property string cacheDir: home + "/.cache/omarchy/" - readonly property string imageCacheDir: cacheDir + "notification-images/" + // The notifications that already left the screen, one file each, trimmed to + // the newest historyLimit. This directory IS the history: `showHistory` + // replays exactly what has been moved in here. + readonly property string historyDir: popupStateDir + "history/" // Corner radius is shared with the menu and shell panels. // It mirrors Hyprland's current decoration:rounding value. readonly property int cornerRadius: Style.cornerRadius @@ -57,7 +57,7 @@ Item { // PersistentProperties handles in-process QML reloads. The on-disk // notifications.json file is the cross-restart backstop — its `dnd` key // is hydrated into persisted.doNotDisturb on startup and written back via - // the same debounced save timer used for history entries. + // a debounced save timer. PersistentProperties { id: persisted reloadableId: "omarchy-notifications" @@ -65,7 +65,7 @@ Item { onDoNotDisturbChanged: { // Suppress the write that load-time hydration would otherwise trigger. if (service._hydrating) return - service.scheduleHistorySave() + service.scheduleSettingsSave() } } @@ -79,27 +79,17 @@ Item { persisted.doNotDisturb = !!value } - // popupModel feeds the on-screen toast stack. - // pendingModel = notifications received but not yet "seen" by the user. - // Anything DND-suppressed lands here and stays there until - // the user reviews it; anything that pops up also lives - // here until the popup dismisses, then moves to pastModel. - // pastModel = notifications the user has already seen on-screen. - // Surfaced under the Past tab in the history panel. + // popupModel feeds the on-screen toast stack — the only model the service + // keeps. Everything a toast leaves behind lives on disk under historyDir. // - // Aliased as properties so the bar widget and HistoryPanel (outside this - // Item's id scope) can bind to them. QML ids aren't visible to external - // consumers without the alias. + // Aliased as a property so consumers outside this Item's id scope can bind + // to it. QML ids aren't visible to external consumers without the alias. property alias popupModel: popupModel - property alias pendingModel: pendingModel - property alias pastModel: pastModel ListModel { id: popupModel } - ListModel { id: pendingModel } - ListModel { id: pastModel } - readonly property int historyCap: 100 - readonly property int historyReplayLimit: 5 - property var imageCacheQueue: [] + // How many notifications the history directory keeps, and therefore how + // many `showHistory` can replay. + readonly property int historyLimit: 10 readonly property int lowPopupDuration: 5000 readonly property int normalPopupDuration: 8000 @@ -141,6 +131,24 @@ Item { return NotificationLogic.snapshotOf(notification, Date.now()) } + // A notification nobody looks back at: + // - the freedesktop `transient` hint is set ("popup only, don't store") + // - app_name is "notify-send" (the CLI default — means the sender + // didn't bother declaring an identity, so it's almost certainly + // ephemeral test/feedback noise) + // - app_name is "omarchy-action" (Omarchy's own user-action toasts — + // the user just triggered them) + // Their toasts still land in history like any other once they've been on + // screen; the distinction only decides whether a DND-silenced one is worth + // recording at all. + function isEphemeral(notification) { + var transient = false + try { + transient = !!(notification.hints && notification.hints["transient"]) + } catch (e) { transient = false } + return transient || NotificationLogic.isEphemeralApp(String(notification.appName || "")) + } + function handleNotification(notification) { // Without `tracked = true` the Notification object is destroyed as soon // as this signal handler returns, which would null out the `ref` we just @@ -154,52 +162,15 @@ Item { if (service.liveRefs[snapshot.originalId] === notification) delete service.liveRefs[snapshot.originalId] }) - // History is for notifications from real apps (Slack, Discord, mailer, - // etc.) — things the user might want to look back at. Skip the pending - // / past bookkeeping when: - // - the freedesktop `transient` hint is set ("popup only, don't store") - // - app_name is "notify-send" (the CLI default — means the sender - // didn't bother declaring an identity, so it's almost certainly - // ephemeral test/feedback noise) - // - app_name is "omarchy-action" (Omarchy's own user-action - // toasts — the user just triggered them, they don't - // need to be archived) - var transient = false - try { - transient = !!(notification.hints && notification.hints["transient"]) - } catch (e) { transient = false } - var appName = String(notification.appName || "") - var ephemeralApp = NotificationLogic.isEphemeralApp(appName) - if (transient || ephemeralApp) { - if (service.doNotDisturb && !shouldBypassDnd(notification)) { - delete liveRefs[snapshot.originalId] - notification.tracked = false - return - } - persistPopupFile(snapshot) - Qt.callLater(function() { - removePopupsByOriginalId(snapshot.originalId, NotificationLogic.popupFileName(snapshot)) - popupModel.insert(0, snapshot) - }) - return - } - // Pending first, unconditionally. DND only suppresses the toast — the - // record still has to land somewhere the user can review later. - addToPending(snapshot) - - // Kick off a copy of any /tmp screenshot into the persistent image cache. - // The cp races the popup; the popup keeps the original path so it always - // renders, and the history row gets rewritten to the cached path once - // cp.exits. - maybeCacheImage(snapshot) - - // DND bypass rules — see ~/Work/omarchy/dnd-fix-plan.md. The pending - // entry already captured this notification above; we just decide here - // whether to also pop a toast. Chat apps abuse urgency=critical to - // force visibility, so critical alone isn't enough — we also require - // the sender to be CLI-style. See shouldBypassDnd(). + // DND bypass rules: chat apps abuse urgency=critical to force + // visibility, so critical alone isn't enough — we also require the + // sender to be CLI-style. See shouldBypassDnd(). if (service.doNotDisturb && !shouldBypassDnd(notification)) { + // 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) delete liveRefs[snapshot.originalId] notification.tracked = false return @@ -223,9 +194,11 @@ Item { return !!row && !!restoredPopups[NotificationLogic.popupFileName(row)] } - // Popup-specific variant of removeByOriginalId: a replaced popup - // (freedesktop replaces_id) leaves the screen, so its persisted file has - // to go too — otherwise it resurrects on the next shell restart. + // A notification arriving under an originalId a popup on screen already + // holds supersedes it, so that row leaves the screen. Its file is deleted + // rather than archived: the row taking its place archives itself when it + // goes, and history would otherwise hold two entries for what the sender + // means as one notification. // keepFileName is the replacement's own file: a same-millisecond // replacement shares the replaced row's filename, and the new write is // already queued — deleting that path here would erase the replacement's @@ -242,85 +215,6 @@ Item { } } - // Remove every row in `model` whose originalId matches. Chat apps reuse - // `replaces_id` per the freedesktop spec to update a single notification - // in place — without this, every Discord/Slack ping leaves a fresh row - // behind and pending fills with hundreds of duplicates. - function removeByOriginalId(model, originalId) { - for (var i = model.count - 1; i >= 0; i--) { - var row = model.get(i) - if (row && row.originalId === originalId) model.remove(i) - } - } - - function addToPending(snapshot) { - Qt.callLater(function() { - removeByOriginalId(pendingModel, snapshot.originalId) - pendingModel.insert(0, snapshot) - while (pendingModel.count > service.historyCap) { - pendingModel.remove(pendingModel.count - 1) - } - scheduleHistorySave() - }) - } - - // Find a pending entry by its libnotify id and move it to pastModel. Called - // when a popup naturally dismisses (timer expired or user clicked X / the - // default action) — the user is assumed to have seen it. Matches on - // timestamp too: a popup row and its pending row are born from the same - // snapshot, and id alone can point at an unrelated notification when a - // restored popup's old-generation id has been reused. - function markSeenByOriginalId(originalId, timestamp) { - Qt.callLater(function() { - for (var i = 0; i < pendingModel.count; i++) { - var entry = pendingModel.get(i) - if (!entry || entry.originalId !== originalId || entry.timestamp !== timestamp) continue - var snapshot = service.snapshotFromRow(entry) - pendingModel.remove(i) - pastModel.insert(0, snapshot) - while (pastModel.count > service.historyCap) { - pastModel.remove(pastModel.count - 1) - } - scheduleHistorySave() - return - } - }) - } - - // Copy a ListModel row into a plain JS object so we can re-insert it into - // a different model without sharing references. - function snapshotFromRow(row) { - return { - id: row.id, - originalId: row.originalId, - app: row.app, - appIcon: row.appIcon, - summary: row.summary, - body: row.body, - image: row.image, - glyph: row.glyph || "", - exec: row.exec || "", - urgency: row.urgency, - expireTimeout: row.expireTimeout || 0, - timestamp: row.timestamp - } - } - - function markAllSeen() { - Qt.callLater(function() { - while (pendingModel.count > 0) { - var entry = pendingModel.get(0) - var snapshot = service.snapshotFromRow(entry) - pendingModel.remove(0) - pastModel.insert(0, snapshot) - } - while (pastModel.count > service.historyCap) { - pastModel.remove(pastModel.count - 1) - } - scheduleHistorySave() - }) - } - function dismissPopup(index) { removePopup(index, "dismiss") } @@ -333,16 +227,17 @@ Item { if (index < 0 || index >= popupModel.count) return var entry = popupModel.get(index) var originalId = entry ? entry.originalId : -1 - var timestamp = entry ? entry.timestamp : 0 // A restored row has no live server object, and its old-generation id // may meanwhile belong to a fresh notification — resolving liveRefs by // id would dismiss that unrelated notification at the server. var restored = isRestoredRow(entry) var ref = !restored && originalId >= 0 ? liveRefs[originalId] : null - // The popup is leaving the screen — for any reason — so its persisted - // file must not survive to the next shell restart. + // The popup is leaving the screen — for any reason — so its file must not + // survive to the next shell restart. It becomes the newest history entry + // instead. Rows that never had a file (a history replay, the empty-history + // placeholder) archive to nothing, which the move tolerates. if (entry) { - deletePopupFileFor(entry) + archivePopupFileFor(entry) if (restored) delete restoredPopups[NotificationLogic.popupFileName(entry)] } popupModel.remove(index) @@ -356,90 +251,12 @@ Item { // Object already torn down by the server — nothing to dismiss. } } - // User (or the lifetime timer) saw the popup — archive it. - if (originalId >= 0) markSeenByOriginalId(originalId, timestamp) } function clearPopups() { while (popupModel.count > 0) dismissPopup(0) } - function rowsFromModel(model) { - var rows = [] - for (var i = 0; i < model.count; i++) { - var entry = model.get(i) - if (entry) rows.push(snapshotFromRow(entry)) - } - return rows - } - - function showRecentHistory() { - var rows = NotificationLogic.recentHistoryRows( - rowsFromModel(pendingModel), - rowsFromModel(pastModel), - service.historyReplayLimit, - NotificationUrgency.Normal) - - // Replaying nothing at all looks like a dead keybinding, so say so. - if (rows.length === 0) { - popupModel.insert(0, { - id: -1, - originalId: -1, - app: "omarchy-action", - appIcon: "", - summary: "No recent notifications", - body: "", - image: "", - glyph: "󰂚", - exec: "", - urgency: NotificationUrgency.Low, - expireTimeout: 0, - timestamp: Date.now() - }) - return "none" - } - - clearPopups() - for (var i = 0; i < rows.length; i++) { - popupModel.append(rows[i]) - } - return "ok" - } - - function dismissPending(index) { - if (index < 0 || index >= pendingModel.count) return - var entry = pendingModel.get(index) - if (entry) maybeDeleteCachedImage(entry.image) - pendingModel.remove(index) - scheduleHistorySave() - } - - function dismissPast(index) { - if (index < 0 || index >= pastModel.count) return - var entry = pastModel.get(index) - if (entry) maybeDeleteCachedImage(entry.image) - pastModel.remove(index) - scheduleHistorySave() - } - - function clearPending() { - for (var i = 0; i < pendingModel.count; i++) { - var entry = pendingModel.get(i) - if (entry) maybeDeleteCachedImage(entry.image) - } - pendingModel.clear() - scheduleHistorySave() - } - - function clearPast() { - for (var i = 0; i < pastModel.count; i++) { - var entry = pastModel.get(i) - if (entry) maybeDeleteCachedImage(entry.image) - } - pastModel.clear() - scheduleHistorySave() - } - // Run the popup's click action, then dismiss. Omarchy's own toasts carry the // action as a command in the `exec` role (see execFromHints), which the // persistence files preserve, so restored toasts stay clickable. Third-party @@ -496,109 +313,18 @@ Item { Process { id: focusAppProc; running: false } - // ---------------------------------------------------- image cache - // - // Notifications coming from screenshot helpers ship an `image-path` hint - // pointing at /tmp/. We want the history thumbnail to outlive that - // file, so we copy it into a long-lived cache dir on ingress and rewrite - // the history row's `image` to point at the cache once cp finishes. - // image:// (raw-bytes) URIs aren't trivially copyable from QML; document - // and skip them for v1. - - function imageExtension(srcPath) { - return NotificationLogic.imageExtension(srcPath) - } - - function maybeCacheImage(snapshot) { - var image = String(snapshot.image || "") - if (!image) return - // image:// URIs are decoded from raw bytes by Quickshell's image provider. - // We can't copy them out from QML, so let history reference them by URI - // and accept that they disappear with the source notification. - if (image.indexOf("image://") === 0) return - if (image.indexOf("file:///tmp/") !== 0) return - - var srcPath = decodeURIComponent(image.substring(7)) - var ext = imageExtension(srcPath) - var destPath = imageCacheDir + snapshot.timestamp + "-" + snapshot.originalId + "." + ext - var destUri = Util.fileUrl(destPath) - - imageCacheQueue = imageCacheQueue.concat([{ - srcPath: srcPath, - destPath: destPath, - targetUri: destUri, - originalId: snapshot.originalId, - timestamp: snapshot.timestamp - }]) - runNextImageCacheJob() - } - - function runNextImageCacheJob() { - if (imageCacheProc.running || imageCacheQueue.length === 0) return - - var job = imageCacheQueue[0] - imageCacheQueue = imageCacheQueue.slice(1) - imageCacheProc.targetUri = job.targetUri - imageCacheProc.matchOriginalId = job.originalId - imageCacheProc.matchTimestamp = job.timestamp - imageCacheProc.command = ["cp", "-f", job.srcPath, job.destPath] - imageCacheProc.running = true - } - - function rewriteCachedImage(targetUri, originalId, timestamp) { - function rewrite(model) { - for (var i = 0; i < model.count; i++) { - var row = model.get(i) - if (row && row.originalId === originalId && row.timestamp === timestamp) { - model.setProperty(i, "image", targetUri) - return true - } - } - return false - } - - return rewrite(pendingModel) || rewrite(pastModel) - } - - function maybeDeleteCachedImage(image) { - var path = String(image || "") - if (!path) return - if (path.indexOf("file://") !== 0) return - var local = decodeURIComponent(path.substring(7)) - if (local.indexOf(imageCacheDir) !== 0) return - deleteImageProc.command = ["rm", "-f", local] - deleteImageProc.running = true - } - Process { id: ensureDirsProc - command: ["mkdir", "-p", service.stateDir, service.popupStateDir, service.imageCacheDir] + command: ["mkdir", "-p", service.stateDir, service.popupStateDir, service.historyDir] running: false } - Process { - id: imageCacheProc - property string targetUri: "" - property int matchOriginalId: -1 - property double matchTimestamp: 0 - onExited: function(exitCode) { - if (exitCode === 0 && targetUri && rewriteCachedImage(targetUri, matchOriginalId, matchTimestamp)) - scheduleHistorySave() - targetUri = "" - matchOriginalId = -1 - matchTimestamp = 0 - runNextImageCacheJob() - } - } - - Process { id: deleteImageProc; running: false } - // ---------------------------------------------------- popup persistence // // Mirror every on-screen popup to its own file under popupStateDir so // toasts survive shell restarts (notably the restart `omarchy-update` - // performs). Writes and deletes go through one serialized queue: a burst - // of replaces_id updates must not race a single reused Process, and + // performs). Writes, moves and deletes go through one serialized queue: a + // burst of replaces_id updates must not race a single reused Process, and // ordering guarantees a delete issued after a write wins. // Popups restored from a previous shell process, keyed by their file @@ -645,6 +371,142 @@ Item { enqueuePopupFileJob(["rm", "-f", popupStateDir + NotificationLogic.popupFileName(row)]) } + // ---------------------------------------------------- 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. + 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" + + 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. + enqueuePopupFileJob(["bash", "-c", + "mkdir -p \"$1\" || exit 0\n" + + "mv -f \"$4/$3\" \"$1/$3\" 2>/dev/null || exit 0\n" + + trimHistoryScript, "--", + historyDir, + String(historyLimit), + NotificationLogic.popupFileName(row), + popupStateDir]) + } + + // Record a notification that never made it to the screen (DND silenced it), + // straight into history. Same file format as an archived popup, so the + // replay can't tell the two apart. + // + // A silenced notification is untracked the moment it arrives, so the server + // has nothing left for a later replaces_id to replace and hands the sender a + // fresh id instead. Every update from a chatty thread is therefore its own + // 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" + + trimHistoryScript, "--", + historyDir, + String(historyLimit), + NotificationLogic.popupFileName(entry), + NotificationLogic.serializePopup(entry, NotificationUrgency.Normal)]) + } + + function clearHistory() { + enqueuePopupFileJob(["bash", "-c", + "rm -f \"$1\"/*.json", "--", historyDir]) + } + + Process { + id: readHistoryProc + running: false + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: service.replayHistory(text) + } + } + + // Toasts that were on screen when the replay was asked for. The clear in + // replayHistory archives them, but the directory read is already in flight + // by then, so they're handed over in memory instead of being waited for. + property var replayCarryOver: [] + + // Re-show what's in historyDir as toasts. Reading the directory is a + // subprocess, so the replay lands in replayHistory a moment later. + function showRecentHistory() { + if (readHistoryProc.running) return "ok" + service.replayCarryOver = liveRowsForReplay() + readHistoryProc.command = ["bash", "-c", + "awk 1 \"$1\"/*.json 2>/dev/null || true", "--", historyDir] + readHistoryProc.running = true + return "ok" + } + + // 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. + 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({ + id: row.id, + originalId: row.originalId, + app: row.app, + appIcon: row.appIcon, + summary: row.summary, + body: row.body, + image: row.image, + glyph: row.glyph || "", + exec: row.exec || "", + urgency: row.urgency, + timestamp: row.timestamp + }) + } + return rows + } + + function replayHistory(raw) { + var rows = NotificationLogic.historyRows( + raw, service.replayCarryOver, NotificationUrgency.Normal, service.historyLimit) + service.replayCarryOver = [] + + // Replaying nothing at all looks like a dead keybinding, so say so. + if (rows.length === 0) { + popupModel.insert(0, { + id: -1, + originalId: -1, + app: "omarchy-action", + appIcon: "", + summary: "No recent notifications", + body: "", + image: "", + glyph: "󰂚", + exec: "", + urgency: NotificationUrgency.Low, + expireTimeout: 0, + timestamp: Date.now() + }) + return + } + + clearPopups() + // Rows arrive newest-first, and index 0 is the top of the toast stack. + for (var i = 0; i < rows.length; i++) { + // Replayed rows are restored rows: their notification died with the + // sender long ago, so they must never resolve to a live server object + // that has since been handed their old id. + service.restoredPopups[NotificationLogic.popupFileName(rows[i])] = true + popupModel.append(rows[i]) + } + } + Process { id: restorePopupsProc running: false @@ -662,7 +524,9 @@ Item { var entry = entries[i] var duration = durationFor(entry.urgency, entry.expireTimeout) if (NotificationLogic.popupExpired(entry, duration, now)) { - deletePopupFileFor(entry) + // It would have expired on screen had the shell kept running, so it + // gets archived exactly like an expiry that happened while it did. + archivePopupFileFor(entry) continue } // Survivors restart with a full lifetime on purpose: shell restarts @@ -710,82 +574,44 @@ Item { }) } - // ---------------------------------------------------- history persistence + // ---------------------------------------------------- settings persistence FileView { - id: historyFile - path: service.historyPath + id: settingsFile + path: service.settingsPath watchChanges: false atomicWrites: true printErrors: false - onLoaded: service.loadHistory(text()) + onLoaded: service.loadSettings(text()) // First-run: the file doesn't exist yet. Without this branch, - // `historyLoaded` stays false forever and `scheduleHistorySave` becomes - // a no-op — so the file is never created and history vanishes on - // shell restart. - onLoadFailed: service.loadHistory("") + // `settingsLoaded` stays false forever and `scheduleSettingsSave` becomes + // a no-op — so the file is never created and the DND preference vanishes + // on shell restart. + onLoadFailed: service.loadSettings("") } Timer { - id: historySaveTimer + id: settingsSaveTimer interval: 200 repeat: false - onTriggered: service.flushHistory() - } - - // Past is a rolling "recently" window. Sweep every minute and drop - // anything older than 15 minutes so the tab doesn't accumulate forever. - readonly property int pastTtlMs: 15 * 60 * 1000 - - Timer { - id: pastPruneTimer - interval: 60 * 1000 - repeat: true - running: true - triggeredOnStart: true - onTriggered: service.prunePast() - } - - function prunePast() { - if (pastModel.count === 0) return - var cutoff = Date.now() - service.pastTtlMs - var removed = false - for (var i = pastModel.count - 1; i >= 0; i--) { - var entry = pastModel.get(i) - if (entry && entry.timestamp && entry.timestamp < cutoff) { - if (entry.image) maybeDeleteCachedImage(entry.image) - pastModel.remove(i) - removed = true - } - } - if (removed) scheduleHistorySave() + onTriggered: service.flushSettings() } - function scheduleHistorySave() { - if (!service.historyLoaded) return - historySaveTimer.restart() + function scheduleSettingsSave() { + if (!service.settingsLoaded) return + settingsSaveTimer.restart() } - property bool historyLoaded: false + property bool settingsLoaded: false - function loadHistory(raw) { + function loadSettings(raw) { // FileView can fire onLoaded more than once during startup — the implicit - // preload when `path` resolves, plus the explicit `historyFile.reload()` - // in Component.onCompleted can both end up calling here. Without this - // guard, the second fire appends a second copy of every persisted row - // to the in-memory model. - if (service.historyLoaded) return - - var parsed = NotificationLogic.parseHistory(raw, NotificationUrgency.Normal, service.historyCap) - if (parsed.empty) { - service.historyLoaded = true - return - } - if (parsed.error) { - console.warn("notifications: history parse failed:", parsed.errorMessage || "") - service.historyLoaded = true - return - } + // preload when `path` resolves, plus the explicit `settingsFile.reload()` + // in Component.onCompleted can both end up calling here. + if (service.settingsLoaded) return + + var parsed = NotificationLogic.parseSettings(raw) + if (parsed.error) console.warn("notifications: settings parse failed:", parsed.errorMessage || "") if (parsed.dnd !== null) { service._hydrating = true @@ -793,54 +619,24 @@ Item { service._hydrating = false } - // Newest-first on disk; append in order so models match. - Qt.callLater(function() { - for (var i = 0; i < parsed.pending.length; i++) pendingModel.append(parsed.pending[i]) - for (var j = 0; j < parsed.past.length; j++) pastModel.append(parsed.past[j]) - service.historyLoaded = true - if (parsed.hadDuplicates) service.scheduleHistorySave() - }) + service.settingsLoaded = true + // Versions before the history moved into its own directory kept every + // notification in here. Rewrite once so that dead payload doesn't sit in + // the file until the next DND toggle happens to clear it. + if (parsed.legacy) service.scheduleSettingsSave() } - function flushHistory() { - function dump(model) { - var out = [] - for (var i = 0; i < model.count; i++) { - var r = model.get(i) - if (!r) continue - out.push({ - id: r.id, - originalId: r.originalId, - app: r.app, - appIcon: r.appIcon, - summary: r.summary, - body: r.body, - image: r.image, - glyph: r.glyph || "", - exec: r.exec || "", - urgency: r.urgency, - expireTimeout: r.expireTimeout || 0, - timestamp: r.timestamp - }) - } - return out - } - var payload = { - version: 2, - dnd: persisted.doNotDisturb, - pending: dump(pendingModel), - past: dump(pastModel) - } - historyFile.setText(JSON.stringify(payload, null, 2) + "\n") + function flushSettings() { + settingsFile.setText(JSON.stringify({ version: 3, dnd: persisted.doNotDisturb }, null, 2) + "\n") } Component.onCompleted: { ensureDirsProc.running = true - // Once mkdir has had a tick, load the existing history file. FileView - // surfaces an empty string when the file doesn't exist; loadHistory + // Once mkdir has had a tick, load the existing settings file. FileView + // surfaces an empty string when the file doesn't exist; loadSettings // handles that path. Qt.callLater(function() { - historyFile.reload() + settingsFile.reload() // Re-show popups that were on screen when the previous shell died. // The glob-through-bash tolerates a missing/empty dir (first run). // awk 1 (not cat) so a torn file missing its trailing newline can't @@ -876,49 +672,27 @@ Item { return dndState() } + // Replay the notifications that have been moved into the history dir. function showHistory(): string { return service.showRecentHistory() } - // `clear` empties the past tab (the "I already saw these" bucket). + // `clear` forgets the recorded history; the toasts on screen stay put. function clear(): string { - service.clearPast() - return "ok" - } - - function clearPending(): string { - service.clearPending() - return "ok" - } - - function markAllSeen(): string { - service.markAllSeen() + service.clearHistory() return "ok" } function dismissAll(): string { service.clearPopups() - service.clearPending() - service.clearPast() return "ok" } - // dismiss the most recent popup; fall back to the most recent pending - // entry, then past, if no popup is currently showing. + // Dismiss the most recent popup. function dismissOne(): string { - if (popupModel.count > 0) { - service.dismissPopup(0) - return "ok" - } - if (pendingModel.count > 0) { - service.dismissPending(0) - return "ok" - } - if (pastModel.count > 0) { - service.dismissPast(0) - return "ok" - } - return "none" + if (popupModel.count === 0) return "none" + service.dismissPopup(0) + return "ok" } // Fire the default action on the most recent popup, then dismiss it. @@ -928,22 +702,19 @@ Item { return "ok" } + // Take a toast off the screen by summary substring, used by the + // first-run notifications once their action has been clicked. function dismiss(summary: string): string { var needle = String(summary || "") if (!needle) return "none" var hit = false - function sweep(model, dismissFn) { - for (var i = model.count - 1; i >= 0; i--) { - var row = model.get(i) - if (row && String(row.summary || "").indexOf(needle) !== -1) { - dismissFn(i) - hit = true - } + for (var i = popupModel.count - 1; i >= 0; i--) { + var row = popupModel.get(i) + if (row && String(row.summary || "").indexOf(needle) !== -1) { + service.dismissPopup(i) + hit = true } } - sweep(pendingModel, service.dismissPending) - sweep(pastModel, service.dismissPast) - sweep(popupModel, service.dismissPopup) return hit ? "ok" : "none" } diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index fc28626566..44692373e6 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -124,64 +124,55 @@ assertDeepEqual( 'notifications create stable snapshots' ) -const history = notifications.parseHistory(JSON.stringify({ - dnd: true, - pending: [ - { id: 1, originalId: 10, summary: 'old', timestamp: 100 }, - { id: 2, originalId: 10, summary: 'new', timestamp: 200 }, - { id: 3, originalId: 11, summary: 'other', timestamp: 150 } - ], - past: [ - { id: 4, summary: 'past', timestamp: 50 } - ], - entries: [ - { id: 5, summary: 'legacy', timestamp: 75 } - ] -}), 1, 100) +const settings = notifications.parseSettings(JSON.stringify({ version: 3, dnd: true })) +assertEqual(settings.dnd, true, 'notifications parse the persisted DND state') +assertEqual(settings.legacy, false, 'notifications do not flag a current settings file as legacy') +assertEqual(notifications.parseSettings('').dnd, null, 'notifications leave DND unset without a settings file') +assertEqual( + notifications.parseSettings(JSON.stringify({ dnd: false, pending: [], past: [] })).legacy, + true, + 'notifications flag a settings file still carrying the retired history rows' +) +assert(notifications.parseSettings('{').error, 'notifications flag invalid settings JSON') -assertEqual(history.dnd, true, 'notifications parse persisted DND state') -assertEqual(history.hadDuplicates, true, 'notifications report duplicate history rows') +// History is the notification files moved into the history dir, read back +// exactly like live popup files. +const archived = [ + notifications.serializePopup({ id: 1, originalId: 1, summary: 'oldest', timestamp: 100 }, 1), + notifications.serializePopup({ id: 2, originalId: 2, summary: 'newest', timestamp: 900, expireTimeout: 30000, deadline: 5000 }, 1), + notifications.serializePopup({ id: 3, originalId: 3, summary: 'middle', timestamp: 500 }, 1) +].join('\n') + +const historyReplay = notifications.historyRows(archived, [], 1, 2) assertDeepEqual( - history.pending.map(row => ({ id: row.id, originalId: row.originalId, summary: row.summary, urgency: row.urgency, timestamp: row.timestamp })), - [ - { id: 2, originalId: 10, summary: 'new', urgency: 1, timestamp: 200 }, - { id: 3, originalId: 11, summary: 'other', urgency: 1, timestamp: 150 } - ], - 'notifications dedupe pending history by original id' + historyReplay.map(row => row.summary), + ['newest', 'middle'], + 'notifications replay the newest history rows up to the limit' ) +assertEqual(historyReplay[0].expireTimeout, 0, 'notifications replay history rows with the standard toast lifetime') +assertEqual('deadline' in historyReplay[0], false, 'notifications drop the restore deadline from replayed history rows') +assertDeepEqual(notifications.historyRows('', [], 1, 10), [], 'notifications replay nothing from an empty history dir') + +// A toast still on screen is the newest notification there is, and its move +// into the history dir races the read, so the replay takes it from memory. assertDeepEqual( - history.past.map(row => row.summary), - ['legacy', 'past'], - 'notifications merge legacy entries into past history' + notifications.historyRows(archived, [{ id: 4, originalId: 4, summary: 'on screen', timestamp: 1500 }], 1, 10) + .map(row => row.summary), + ['on screen', 'newest', 'middle', 'oldest'], + 'notifications replay the toasts still on screen alongside the archived ones' ) assertDeepEqual( - notifications.parseHistory(JSON.stringify({ pending: [{ id: 1, timestamp: 1 }] }), 1, 0).pending, - [], - 'notifications history parser supports zero result cap' -) -assert(notifications.parseHistory('{', 1, 100).error, 'notifications flag invalid history JSON') - -const recentRows = notifications.recentHistoryRows( - [ - { id: 1, originalId: 10, summary: 'pending-old', timestamp: 100 }, - { id: 2, originalId: 11, summary: 'pending-new', timestamp: 700 }, - { id: 3, originalId: 12, summary: 'pending-mid', timestamp: 300 } - ], - [ - { id: 4, originalId: 13, summary: 'past-newest', timestamp: 900 }, - { id: 5, originalId: 14, summary: 'past-second', timestamp: 800 }, - { id: 6, originalId: 10, summary: 'past-replaced', timestamp: 200 }, - { id: 7, originalId: 15, summary: 'past-extra', timestamp: 50 } - ], - 5, - 1 + notifications.historyRows(archived, [{ id: 2, originalId: 2, summary: 'newest', timestamp: 900 }], 1, 10) + .map(row => row.summary), + ['newest', 'middle', 'oldest'], + 'notifications replay a toast once when its archived file already landed' ) assertDeepEqual( - recentRows.map(row => row.summary), - ['past-newest', 'past-second', 'pending-new', 'pending-mid', 'past-replaced'], - 'notifications pick the last five history rows across pending and past' + notifications.historyRows('', [{ id: 4, originalId: 4, summary: 'on screen', timestamp: 1500 }], 1, 10) + .map(row => row.summary), + ['on screen'], + 'notifications replay an on-screen toast even when nothing is archived yet' ) -assertEqual(recentRows.length, 5, 'notifications history replay is capped at five rows') const popup = { id: 7, @@ -286,20 +277,11 @@ assertEqual( 'xdg-open /tmp/received', 'notifications keep the click command on history rows' ) -assertEqual( - notifications.dumpRows([{ id: 1, exec: 'xdg-open /tmp/received' }])[0].exec, - 'xdg-open /tmp/received', - 'notifications write the click command back out with history' -) - -assertEqual(notifications.imageExtension('/tmp/screenshot.PNG'), 'png', 'notifications normalize image extensions') -assertEqual(notifications.imageExtension('/tmp/no-extension'), 'png', 'notifications default missing image extension') -assertEqual(notifications.imageExtension('/tmp/archive.reallylong'), 'png', 'notifications reject suspicious image extensions') const serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/Service.qml'), 'utf8') assert( - /readonly property int historyReplayLimit: 5/.test(serviceQml), - 'notifications service limits history replay to five rows' + /readonly property int historyLimit: 10/.test(serviceQml), + 'notifications service keeps the last ten notifications in history' ) assert( /function showHistory\(\): string \{\s*return service\.showRecentHistory\(\)\s*\}/.test(serviceQml), @@ -310,12 +292,32 @@ assert( 'notifications service persists popups under the omarchy state dir' ) assert( - serviceQml.split('persistPopupFile(snapshot)').length === 4, - 'notifications service persists both ephemeral and regular popups' + /readonly property string historyDir: popupStateDir \+ "history\/"/.test(serviceQml), + 'notifications service keeps history in a subdirectory of the popup state dir' +) +assert( + /if \(entry\) \{\s*\n\s*archivePopupFileFor\(entry\)[\s\S]{0,200}?popupModel\.remove\(index\)/.test(serviceQml), + 'notifications service archives the popup file when a popup leaves the screen' +) +assert( + /mv -f \\"\$4\/\$3\\" \\"\$1\/\$3\\"/.test(serviceQml), + 'notifications service archives by moving the popup file into the history dir' +) +assert( + /head -n \\"-\$2\\"/.test(serviceQml), + 'notifications service trims history to the newest entries in the same job' +) +assert( + /if \(!isEphemeral\(notification\)\) writeHistoryFile\(snapshot\)/.test(serviceQml), + 'notifications service records DND-silenced notifications straight into history' +) +assert( + /service\.replayCarryOver = liveRowsForReplay\(\)/.test(serviceQml), + 'notifications service carries the toasts still on screen into the replay' ) assert( - /if \(entry\) \{\s*\n\s*deletePopupFileFor\(entry\)[\s\S]{0,200}?popupModel\.remove\(index\)/.test(serviceQml), - 'notifications service deletes the popup file when a popup leaves the screen' + /awk 1 \\"\$1\\"\/\*\.json 2>\/dev\/null \|\| true", "--", historyDir/.test(serviceQml), + 'notifications service replays history by reading the archived files' ) assert( /restorePopupsProc\.running = true/.test(serviceQml), @@ -330,8 +332,8 @@ assert( 'notifications service never resolves a restored popup to a live server object' ) assert( - /markSeenByOriginalId\(originalId, timestamp\)/.test(serviceQml), - 'notifications service archives pending rows by id and timestamp' + /service\.restoredPopups\[NotificationLogic\.popupFileName\(rows\[i\]\)\] = true/.test(serviceQml), + 'notifications service treats replayed history rows as restored, never as live notifications' ) assert( /popupFileName\(row\) !== keepFileName/.test(serviceQml), @@ -346,11 +348,11 @@ assert( 'notifications service runs the popup click command itself instead of a libnotify action' ) assert( - /exec: row\.exec \|\| ""/.test(serviceQml), - 'notifications service carries the click command between models' + /function clear\(\): string \{\s*service\.clearHistory\(\)/.test(serviceQml), + 'notifications clear IPC forgets the recorded history' ) assert( - /exec: r\.exec \|\| ""/.test(serviceQml), - 'notifications service saves the click command with history' + !/pendingModel|pastModel/.test(serviceQml), + 'notifications service keeps no in-memory history models' ) JS