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:
David Heinemeier Hansson
2026-08-07 15:14:56 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 3d033d1b00
commit 48d77b5738
3 changed files with 370 additions and 10 deletions
+108
View File
@@ -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