Persist on-screen notification popups across shell restarts (#6600)
* Persist on-screen notification popups across shell restarts Mirror every popup to its own file under ~/.local/state/omarchy/notifications/ for exactly as long as it is on screen: written when the toast appears, deleted when it expires, is dismissed, is acted upon, or is replaced via freedesktop replaces_id. On startup the directory is read back and still-valid popups re-shown, so toasts survive the restart omarchy-update performs — critical alerts, which never expire, always make it across. Restored popups keep ids from the previous server generation, so the replaces_id cleanup tracks them separately instead of mistaking a fresh notification's reused id for a replacement, and the startup restore only discards a persisted file when a live row with a different timestamp has superseded it. Files are read back with awk so a torn write can't glue itself onto the next file and take a valid popup down with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Close the remaining cross-generation id collisions in popup persistence Notification ids restart from 1 with every server process, so an id alone never identifies a notification across a shell restart. The first round of fixes guarded row removal, but review (and a live repro) showed the same collision biting everywhere else an id was used on its own: - Dismissing or clicking a restored toast resolved liveRefs by id and could dismiss, or fire the action of, an unrelated fresh notification, and archive its pending row. Restored rows now never resolve to a live object, and pending rows are matched by id plus timestamp. - parsePopupFiles deduped files by id, so a fresh notification reusing a restored critical alert's id got that alert's file deleted as a "stale duplicate" on the next restore. Files are never deduped now: each one is a popup that was on screen, and the rare genuine leftover from a crash re-shows once and cleans itself up. - The restore only skips an entry when a live row matches both id and timestamp (it is that entry); an id-only match shows both toasts rather than guessing which one to drop. - A same-millisecond replaces_id update shares its predecessor's filename; the replacement's file is no longer deleted alongside the replaced row. - A restored popup's reset lifetime is persisted as an absolute deadline, so a second restart judges it by the clock that actually governs its display instead of dropping it while still on screen. 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
3d033d1b00
commit
48d77b5738
@@ -194,6 +194,77 @@ function dumpRows(rows) {
|
|||||||
return out
|
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.
|
||||||
|
|
||||||
|
function popupEntry(value, normalUrgency) {
|
||||||
|
var entry = historyEntry(value, normalUrgency)
|
||||||
|
var expire = Number((value || {}).expireTimeout || 0)
|
||||||
|
if (!isFinite(expire) || expire < 0) expire = 0
|
||||||
|
entry.expireTimeout = expire
|
||||||
|
// Absolute expiry deadline, set only when a restore resets a surviving
|
||||||
|
// popup's display lifetime. Kept out of the entry entirely when unset so
|
||||||
|
// restored rows match the roles of freshly received ones.
|
||||||
|
var deadline = Number((value || {}).deadline || 0)
|
||||||
|
if (isFinite(deadline) && deadline > 0) entry.deadline = deadline
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
function popupFileName(entry) {
|
||||||
|
var e = entry || {}
|
||||||
|
return String(e.timestamp || 0) + "-" + String(e.originalId || 0) + ".json"
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializePopup(entry, normalUrgency) {
|
||||||
|
// Compact (single-line) on purpose: restore cats every file together and
|
||||||
|
// parses line by line, which only works when each file is one line.
|
||||||
|
return JSON.stringify(popupEntry(entry, normalUrgency))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the concatenation of every persisted popup file into entries,
|
||||||
|
// newest-first. Deliberately NO dedupe by originalId: ids restart from 1
|
||||||
|
// with every server process, so two files sharing an id are usually
|
||||||
|
// different generations — dropping the older one would silently discard a
|
||||||
|
// restored critical alert the moment a fresh notification reuses its id.
|
||||||
|
// The one case that leaves a genuine duplicate (a crash between a
|
||||||
|
// replacement's write and the replaced file's delete) merely re-shows a
|
||||||
|
// superseded toast, which expires or is dismissed and cleans itself up.
|
||||||
|
function parsePopupFiles(raw, normalUrgency) {
|
||||||
|
var lines = String(raw || "").split("\n")
|
||||||
|
var entries = []
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
var line = lines[i].trim()
|
||||||
|
if (!line) continue
|
||||||
|
try {
|
||||||
|
var value = JSON.parse(line)
|
||||||
|
if (value && typeof value === "object") entries.push(popupEntry(value, normalUrgency))
|
||||||
|
} catch (e) {
|
||||||
|
// A torn write from a crash mid-save — skip the line, keep the rest.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// A persisted popup whose lifetime already ran out would have expired on
|
||||||
|
// screen had the shell kept running, so it is not restored. duration 0 means
|
||||||
|
// the popup never expires (critical urgency) and always survives restarts.
|
||||||
|
// A restore-reset deadline outranks the original timestamp: without it, a
|
||||||
|
// second restart would judge a re-shown toast by a clock that no longer
|
||||||
|
// governs its display and drop it while it is still on screen.
|
||||||
|
function popupExpired(entry, duration, now) {
|
||||||
|
var deadline = Number((entry || {}).deadline || 0)
|
||||||
|
if (isFinite(deadline) && deadline > 0) return Number(now) >= deadline
|
||||||
|
var lifetime = Number(duration || 0)
|
||||||
|
if (!isFinite(lifetime) || lifetime <= 0) return false
|
||||||
|
return (Number(now) - Number((entry || {}).timestamp || 0)) >= lifetime
|
||||||
|
}
|
||||||
|
|
||||||
function popupPlacement(barPosition, barClearance, gapsOut) {
|
function popupPlacement(barPosition, barClearance, gapsOut) {
|
||||||
var position = String(barPosition || "top")
|
var position = String(barPosition || "top")
|
||||||
var clearance = Number(barClearance)
|
var clearance = Number(barClearance)
|
||||||
@@ -236,6 +307,11 @@ if (typeof module !== "undefined") {
|
|||||||
parseHistory: parseHistory,
|
parseHistory: parseHistory,
|
||||||
recentHistoryRows: recentHistoryRows,
|
recentHistoryRows: recentHistoryRows,
|
||||||
dumpRows: dumpRows,
|
dumpRows: dumpRows,
|
||||||
|
popupEntry: popupEntry,
|
||||||
|
popupFileName: popupFileName,
|
||||||
|
serializePopup: serializePopup,
|
||||||
|
parsePopupFiles: parsePopupFiles,
|
||||||
|
popupExpired: popupExpired,
|
||||||
popupPlacement: popupPlacement,
|
popupPlacement: popupPlacement,
|
||||||
imageExtension: imageExtension
|
imageExtension: imageExtension
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ Item {
|
|||||||
// regeneratable cache that a `rm -rf ~/.cache` should wipe.
|
// regeneratable cache that a `rm -rf ~/.cache` should wipe.
|
||||||
readonly property string stateDir: home + "/.local/state/omarchy/"
|
readonly property string stateDir: home + "/.local/state/omarchy/"
|
||||||
readonly property string historyPath: stateDir + "notifications.json"
|
readonly property string historyPath: 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.
|
||||||
|
readonly property string popupStateDir: stateDir + "notifications/"
|
||||||
// Thumbnails copied from /tmp screenshots are genuinely disposable — if
|
// Thumbnails copied from /tmp screenshots are genuinely disposable — if
|
||||||
// they vanish the row just renders without an image — so they stay in
|
// they vanish the row just renders without an image — so they stay in
|
||||||
// ~/.cache where regeneratable artifacts belong.
|
// ~/.cache where regeneratable artifacts belong.
|
||||||
@@ -172,8 +176,9 @@ Item {
|
|||||||
notification.tracked = false
|
notification.tracked = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
persistPopupFile(snapshot)
|
||||||
Qt.callLater(function() {
|
Qt.callLater(function() {
|
||||||
removeByOriginalId(popupModel, snapshot.originalId)
|
removePopupsByOriginalId(snapshot.originalId, NotificationLogic.popupFileName(snapshot))
|
||||||
popupModel.insert(0, snapshot)
|
popupModel.insert(0, snapshot)
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -200,14 +205,43 @@ Item {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
persistPopupFile(snapshot)
|
||||||
// Qt.callLater avoids "QV4::Object::insertMember" crashes when a
|
// Qt.callLater avoids "QV4::Object::insertMember" crashes when a
|
||||||
// Repeater is mid-incubation while we mutate its model.
|
// Repeater is mid-incubation while we mutate its model.
|
||||||
Qt.callLater(function() {
|
Qt.callLater(function() {
|
||||||
removeByOriginalId(popupModel, snapshot.originalId)
|
removePopupsByOriginalId(snapshot.originalId, NotificationLogic.popupFileName(snapshot))
|
||||||
popupModel.insert(0, snapshot)
|
popupModel.insert(0, snapshot)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A restored row carries an id from the previous server generation, and
|
||||||
|
// the new server hands out ids from 1 again — so a fresh notification
|
||||||
|
// with the same originalId is a coincidence, not the same notification.
|
||||||
|
// The timestamp (via the file name) disambiguates: it travels with the
|
||||||
|
// row through every model and file round-trip.
|
||||||
|
function isRestoredRow(row) {
|
||||||
|
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.
|
||||||
|
// 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
|
||||||
|
// only file.
|
||||||
|
function removePopupsByOriginalId(originalId, keepFileName) {
|
||||||
|
for (var i = popupModel.count - 1; i >= 0; i--) {
|
||||||
|
var row = popupModel.get(i)
|
||||||
|
if (!row || row.originalId !== originalId) continue
|
||||||
|
// Not a replaces_id match — see isRestoredRow. Removing it here
|
||||||
|
// would silently kill a restored critical alert on an unrelated ping.
|
||||||
|
if (isRestoredRow(row)) continue
|
||||||
|
if (NotificationLogic.popupFileName(row) !== keepFileName) deletePopupFileFor(row)
|
||||||
|
popupModel.remove(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Remove every row in `model` whose originalId matches. Chat apps reuse
|
// Remove every row in `model` whose originalId matches. Chat apps reuse
|
||||||
// `replaces_id` per the freedesktop spec to update a single notification
|
// `replaces_id` per the freedesktop spec to update a single notification
|
||||||
// in place — without this, every Discord/Slack ping leaves a fresh row
|
// in place — without this, every Discord/Slack ping leaves a fresh row
|
||||||
@@ -232,12 +266,15 @@ Item {
|
|||||||
|
|
||||||
// Find a pending entry by its libnotify id and move it to pastModel. Called
|
// 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
|
// when a popup naturally dismisses (timer expired or user clicked X / the
|
||||||
// default action) — the user is assumed to have seen it.
|
// default action) — the user is assumed to have seen it. Matches on
|
||||||
function markSeenByOriginalId(originalId) {
|
// 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() {
|
Qt.callLater(function() {
|
||||||
for (var i = 0; i < pendingModel.count; i++) {
|
for (var i = 0; i < pendingModel.count; i++) {
|
||||||
var entry = pendingModel.get(i)
|
var entry = pendingModel.get(i)
|
||||||
if (!entry || entry.originalId !== originalId) continue
|
if (!entry || entry.originalId !== originalId || entry.timestamp !== timestamp) continue
|
||||||
var snapshot = service.snapshotFromRow(entry)
|
var snapshot = service.snapshotFromRow(entry)
|
||||||
pendingModel.remove(i)
|
pendingModel.remove(i)
|
||||||
pastModel.insert(0, snapshot)
|
pastModel.insert(0, snapshot)
|
||||||
@@ -295,7 +332,18 @@ Item {
|
|||||||
if (index < 0 || index >= popupModel.count) return
|
if (index < 0 || index >= popupModel.count) return
|
||||||
var entry = popupModel.get(index)
|
var entry = popupModel.get(index)
|
||||||
var originalId = entry ? entry.originalId : -1
|
var originalId = entry ? entry.originalId : -1
|
||||||
var ref = originalId >= 0 ? liveRefs[originalId] : null
|
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.
|
||||||
|
if (entry) {
|
||||||
|
deletePopupFileFor(entry)
|
||||||
|
if (restored) delete restoredPopups[NotificationLogic.popupFileName(entry)]
|
||||||
|
}
|
||||||
popupModel.remove(index)
|
popupModel.remove(index)
|
||||||
if (ref) {
|
if (ref) {
|
||||||
try {
|
try {
|
||||||
@@ -308,7 +356,7 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// User (or the lifetime timer) saw the popup — archive it.
|
// User (or the lifetime timer) saw the popup — archive it.
|
||||||
if (originalId >= 0) markSeenByOriginalId(originalId)
|
if (originalId >= 0) markSeenByOriginalId(originalId, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPopups() {
|
function clearPopups() {
|
||||||
@@ -397,7 +445,9 @@ Item {
|
|||||||
function invokePopupDefault(index) {
|
function invokePopupDefault(index) {
|
||||||
if (index < 0 || index >= popupModel.count) return
|
if (index < 0 || index >= popupModel.count) return
|
||||||
var entry = popupModel.get(index)
|
var entry = popupModel.get(index)
|
||||||
var ref = entry ? liveRefs[entry.originalId] : null
|
// Restored rows have no live actions, and looking up liveRefs by their
|
||||||
|
// old-generation id could fire an unrelated fresh notification's action.
|
||||||
|
var ref = entry && !isRestoredRow(entry) ? liveRefs[entry.originalId] : null
|
||||||
var invoked = false
|
var invoked = false
|
||||||
try {
|
try {
|
||||||
if (ref && ref.actions) {
|
if (ref && ref.actions) {
|
||||||
@@ -511,7 +561,7 @@ Item {
|
|||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: ensureDirsProc
|
id: ensureDirsProc
|
||||||
command: ["mkdir", "-p", service.stateDir, service.imageCacheDir]
|
command: ["mkdir", "-p", service.stateDir, service.popupStateDir, service.imageCacheDir]
|
||||||
running: false
|
running: false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,6 +582,123 @@ Item {
|
|||||||
|
|
||||||
Process { id: deleteImageProc; running: false }
|
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
|
||||||
|
// ordering guarantees a delete issued after a write wins.
|
||||||
|
|
||||||
|
// Popups restored from a previous shell process, keyed by their file
|
||||||
|
// name (timestamp-originalId) since ids alone repeat across server
|
||||||
|
// generations. The replaces_id handling and liveRefs lookups must not
|
||||||
|
// match these rows against fresh notifications.
|
||||||
|
property var restoredPopups: ({})
|
||||||
|
|
||||||
|
property var popupFileQueue: []
|
||||||
|
|
||||||
|
function enqueuePopupFileJob(command) {
|
||||||
|
popupFileQueue = popupFileQueue.concat([command])
|
||||||
|
runNextPopupFileJob()
|
||||||
|
}
|
||||||
|
|
||||||
|
function runNextPopupFileJob() {
|
||||||
|
if (popupFileProc.running || popupFileQueue.length === 0) return
|
||||||
|
popupFileProc.command = popupFileQueue[0]
|
||||||
|
popupFileQueue = popupFileQueue.slice(1)
|
||||||
|
popupFileProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: popupFileProc
|
||||||
|
running: false
|
||||||
|
onExited: service.runNextPopupFileJob()
|
||||||
|
}
|
||||||
|
|
||||||
|
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\"", "--",
|
||||||
|
popupStateDir,
|
||||||
|
NotificationLogic.serializePopup(snapshot, NotificationUrgency.Normal),
|
||||||
|
NotificationLogic.popupFileName(snapshot)])
|
||||||
|
}
|
||||||
|
|
||||||
|
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)])
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: restorePopupsProc
|
||||||
|
running: false
|
||||||
|
stdout: StdioCollector {
|
||||||
|
waitForEnd: true
|
||||||
|
onStreamFinished: service.restorePopups(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restorePopups(raw) {
|
||||||
|
var entries = NotificationLogic.parsePopupFiles(raw, NotificationUrgency.Normal)
|
||||||
|
var now = Date.now()
|
||||||
|
var live = []
|
||||||
|
for (var i = 0; i < entries.length; i++) {
|
||||||
|
var entry = entries[i]
|
||||||
|
var duration = durationFor(entry.urgency, entry.expireTimeout)
|
||||||
|
if (NotificationLogic.popupExpired(entry, duration, now)) {
|
||||||
|
deletePopupFileFor(entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Survivors restart with a full lifetime on purpose: shell restarts
|
||||||
|
// are rare, and a full look after the restart flicker beats resuming
|
||||||
|
// a toast with a second left on its clock. The reset is persisted as
|
||||||
|
// an absolute deadline so a second restart while the toast is still
|
||||||
|
// on screen judges it by the reset clock, not the original timestamp.
|
||||||
|
if (duration > 0) {
|
||||||
|
entry.deadline = now + duration
|
||||||
|
persistPopupFile(entry)
|
||||||
|
// deadline is persistence metadata, not a model role — fresh rows
|
||||||
|
// never carry it, and ListModel roles must stay consistent.
|
||||||
|
delete entry.deadline
|
||||||
|
}
|
||||||
|
live.push(entry)
|
||||||
|
}
|
||||||
|
if (live.length === 0) return
|
||||||
|
|
||||||
|
Qt.callLater(function() {
|
||||||
|
for (var j = 0; j < live.length; j++) {
|
||||||
|
var restored = live[j]
|
||||||
|
// A notification received while the restore was reading the dir can
|
||||||
|
// already occupy this originalId with the same timestamp — then it
|
||||||
|
// IS this entry, live with its own file, and must be left alone. A
|
||||||
|
// different timestamp is indistinguishable between a genuine
|
||||||
|
// cross-restart replaces_id and a new-generation id coincidence, so
|
||||||
|
// show both: a briefly duplicated toast beats silently dropping a
|
||||||
|
// restored critical alert.
|
||||||
|
var duplicate = false
|
||||||
|
for (var k = 0; k < popupModel.count; k++) {
|
||||||
|
var row = popupModel.get(k)
|
||||||
|
if (row && row.originalId === restored.originalId && row.timestamp === restored.timestamp) {
|
||||||
|
duplicate = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (duplicate) continue
|
||||||
|
// Append (entries are newest-first) so restored toasts stack in
|
||||||
|
// their original order below anything that just arrived. Restored
|
||||||
|
// popups have no liveRefs entry — the server object died with the
|
||||||
|
// old shell — so dismissal and action fallbacks degrade gracefully.
|
||||||
|
service.restoredPopups[NotificationLogic.popupFileName(restored)] = true
|
||||||
|
popupModel.append(restored)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------- history persistence
|
// ---------------------------------------------------- history persistence
|
||||||
|
|
||||||
FileView {
|
FileView {
|
||||||
@@ -660,7 +827,16 @@ Item {
|
|||||||
// Once mkdir has had a tick, load the existing history file. FileView
|
// Once mkdir has had a tick, load the existing history file. FileView
|
||||||
// surfaces an empty string when the file doesn't exist; loadHistory
|
// surfaces an empty string when the file doesn't exist; loadHistory
|
||||||
// handles that path.
|
// handles that path.
|
||||||
Qt.callLater(function() { historyFile.reload() })
|
Qt.callLater(function() {
|
||||||
|
historyFile.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
|
||||||
|
// glue itself onto the next file and take a valid popup down with it.
|
||||||
|
restorePopupsProc.command = ["bash", "-c",
|
||||||
|
"awk 1 \"$1\"/*.json 2>/dev/null || true", "--", service.popupStateDir]
|
||||||
|
restorePopupsProc.running = true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------- IPC
|
// ---------------------------------------------------- IPC
|
||||||
|
|||||||
@@ -183,6 +183,78 @@ assertDeepEqual(
|
|||||||
)
|
)
|
||||||
assertEqual(recentRows.length, 5, 'notifications history replay is capped at five rows')
|
assertEqual(recentRows.length, 5, 'notifications history replay is capped at five rows')
|
||||||
|
|
||||||
|
const popup = {
|
||||||
|
id: 7,
|
||||||
|
originalId: 7,
|
||||||
|
app: 'Mail',
|
||||||
|
appIcon: 'mail',
|
||||||
|
summary: 'New message',
|
||||||
|
body: 'Body',
|
||||||
|
image: '',
|
||||||
|
glyph: '',
|
||||||
|
urgency: 2,
|
||||||
|
expireTimeout: 2500,
|
||||||
|
timestamp: 1000
|
||||||
|
}
|
||||||
|
assertEqual(notifications.popupFileName(popup), '1000-7.json', 'notifications name popup files by timestamp and id')
|
||||||
|
assertEqual(
|
||||||
|
notifications.serializePopup(popup, 1).indexOf('\n'),
|
||||||
|
-1,
|
||||||
|
'notifications serialize popups to a single line'
|
||||||
|
)
|
||||||
|
assertEqual(
|
||||||
|
notifications.popupEntry({ id: 1, timestamp: 5 }, 1).urgency,
|
||||||
|
1,
|
||||||
|
'notifications default popup urgency to normal'
|
||||||
|
)
|
||||||
|
assertEqual(
|
||||||
|
notifications.popupEntry({ id: 1, timestamp: 5, expireTimeout: 4000 }, 1).expireTimeout,
|
||||||
|
4000,
|
||||||
|
'notifications preserve popup expire timeouts unlike history rows'
|
||||||
|
)
|
||||||
|
|
||||||
|
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: 'new-generation', urgency: 1, timestamp: 300 }, 1),
|
||||||
|
notifications.serializePopup({ id: 2, originalId: 2, summary: 'critical', urgency: 2, timestamp: 200 }, 1),
|
||||||
|
'{ torn write'
|
||||||
|
].join('\n'),
|
||||||
|
1
|
||||||
|
)
|
||||||
|
assertDeepEqual(
|
||||||
|
popupFiles.map(row => row.summary),
|
||||||
|
['new-generation', 'critical', 'old-generation'],
|
||||||
|
'notifications restore every persisted popup newest-first, never deduping ids across server generations'
|
||||||
|
)
|
||||||
|
assertDeepEqual(
|
||||||
|
notifications.parsePopupFiles('', 1),
|
||||||
|
[],
|
||||||
|
'notifications restore nothing from an empty popup dir'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert(!notifications.popupExpired({ timestamp: 0 }, 0, 999999), 'critical popups never expire on restore')
|
||||||
|
assert(!notifications.popupExpired({ timestamp: 1000 }, 8000, 5000), 'popups within their lifetime are restored')
|
||||||
|
assert(notifications.popupExpired({ timestamp: 1000 }, 8000, 9000), 'popups past their lifetime are not restored')
|
||||||
|
assert(
|
||||||
|
!notifications.popupExpired({ timestamp: 1000, deadline: 20000 }, 8000, 15000),
|
||||||
|
'a restore-reset deadline outranks the original popup timestamp'
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
notifications.popupExpired({ timestamp: 1000, deadline: 20000 }, 8000, 20000),
|
||||||
|
'popups past their reset deadline are not restored'
|
||||||
|
)
|
||||||
|
assertEqual(
|
||||||
|
notifications.popupEntry(JSON.parse(notifications.serializePopup({ id: 1, originalId: 1, timestamp: 5, deadline: 9000 }, 1)), 1).deadline,
|
||||||
|
9000,
|
||||||
|
'notifications round-trip reset deadlines through popup files'
|
||||||
|
)
|
||||||
|
assertEqual(
|
||||||
|
'deadline' in notifications.popupEntry({ id: 1, timestamp: 5 }, 1),
|
||||||
|
false,
|
||||||
|
'notifications omit the deadline field until a restore sets it'
|
||||||
|
)
|
||||||
|
|
||||||
assertEqual(notifications.imageExtension('/tmp/screenshot.PNG'), 'png', 'notifications normalize image extensions')
|
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/no-extension'), 'png', 'notifications default missing image extension')
|
||||||
assertEqual(notifications.imageExtension('/tmp/archive.reallylong'), 'png', 'notifications reject suspicious image extensions')
|
assertEqual(notifications.imageExtension('/tmp/archive.reallylong'), 'png', 'notifications reject suspicious image extensions')
|
||||||
@@ -196,4 +268,40 @@ assert(
|
|||||||
/function showHistory\(\): string \{\s*return service\.showRecentHistory\(\)\s*\}/.test(serviceQml),
|
/function showHistory\(\): string \{\s*return service\.showRecentHistory\(\)\s*\}/.test(serviceQml),
|
||||||
'notifications history IPC replays recent notifications'
|
'notifications history IPC replays recent notifications'
|
||||||
)
|
)
|
||||||
|
assert(
|
||||||
|
/readonly property string popupStateDir: stateDir \+ "notifications\/"/.test(serviceQml),
|
||||||
|
'notifications service persists popups under the omarchy state dir'
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
serviceQml.split('persistPopupFile(snapshot)').length === 4,
|
||||||
|
'notifications service persists both ephemeral and regular popups'
|
||||||
|
)
|
||||||
|
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'
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
/restorePopupsProc\.running = true/.test(serviceQml),
|
||||||
|
'notifications service restores persisted popups on startup'
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
/if \(isRestoredRow\(row\)\) continue/.test(serviceQml),
|
||||||
|
'notifications service protects restored popups from new-generation id collisions'
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
/var ref = !restored && originalId >= 0 \? liveRefs\[originalId\] : null/.test(serviceQml),
|
||||||
|
'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'
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
/popupFileName\(row\) !== keepFileName/.test(serviceQml),
|
||||||
|
'notifications service keeps a same-millisecond replacement popup file'
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
/awk 1 \\"\$1\\"\/\*\.json/.test(serviceQml),
|
||||||
|
'notifications service delimits every popup file during restore'
|
||||||
|
)
|
||||||
JS
|
JS
|
||||||
|
|||||||
Reference in New Issue
Block a user