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
@@ -183,6 +183,78 @@ assertDeepEqual(
|
||||
)
|
||||
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/no-extension'), 'png', 'notifications default missing image extension')
|
||||
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),
|
||||
'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
|
||||
|
||||
Reference in New Issue
Block a user