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) <noreply@anthropic.com>
849 lines
32 KiB
QML
849 lines
32 KiB
QML
// Notification service for the omarchy shell.
|
|
|
|
import QtQuick
|
|
import QtQuick.Layouts
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import Quickshell.Wayland
|
|
import Quickshell.Services.Notifications
|
|
import qs.Commons
|
|
|
|
import "components"
|
|
import "NotificationLogic.js" as NotificationLogic
|
|
|
|
Item {
|
|
id: service
|
|
|
|
// Injected by omarchy-shell (the first-party service loader).
|
|
property var shell: null
|
|
|
|
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
|
|
// (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 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, moved into historyDir when it expires, is dismissed, or is
|
|
// acted upon.
|
|
readonly property string popupStateDir: stateDir + "notifications/"
|
|
// 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
|
|
// Toasts are fixed to the top-right corner. They only clear the omarchy bar
|
|
// when the bar occupies the top or right edge, so left/bottom bars do not
|
|
// pull notification popups away from the expected top-right location.
|
|
// Falls back to the bar's default size (26 horizontal / 28 vertical) when
|
|
// shell.bar isn't reachable so the popup never lands on top of the bar.
|
|
readonly property string barPosition: shell && shell.barConfig ? String(shell.barConfig.position || "top") : "top"
|
|
readonly property bool barVertical: barPosition === "left" || barPosition === "right"
|
|
readonly property int defaultBarSize: barVertical ? Style.bar.sizeVertical : Style.bar.sizeHorizontal
|
|
readonly property int liveBarSize: shell && shell.bar && !shell.bar.barHidden ? Math.max(0, shell.bar.barSize) : defaultBarSize
|
|
readonly property int barClearance: liveBarSize + Style.gapsOut
|
|
|
|
// Live Notification objects by originalId, kept OUT of the ListModels: a
|
|
// QObject stored in a model role becomes a dangling C++ pointer when the
|
|
// server destroys the notification (sender close, DND untrack, dismiss),
|
|
// and the next read of that role segfaults in QQmlListModel::data. A JS
|
|
// map only holds a wrapper, which degrades to a catchable error instead.
|
|
property var liveRefs: ({})
|
|
|
|
// 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
|
|
// a debounced save timer.
|
|
PersistentProperties {
|
|
id: persisted
|
|
reloadableId: "omarchy-notifications"
|
|
property bool doNotDisturb: false
|
|
onDoNotDisturbChanged: {
|
|
// Suppress the write that load-time hydration would otherwise trigger.
|
|
if (service._hydrating) return
|
|
service.scheduleSettingsSave()
|
|
}
|
|
}
|
|
|
|
// Guards onDoNotDisturbChanged while we're hydrating from disk so the
|
|
// hydration assignment doesn't immediately schedule a write-back.
|
|
property bool _hydrating: false
|
|
|
|
readonly property alias doNotDisturb: persisted.doNotDisturb
|
|
|
|
function setDoNotDisturb(value) {
|
|
persisted.doNotDisturb = !!value
|
|
}
|
|
|
|
// 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 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
|
|
ListModel { id: popupModel }
|
|
|
|
// 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
|
|
readonly property int maxPopupDuration: 30000
|
|
|
|
function durationFor(urgency, expireTimeout) {
|
|
switch (urgency) {
|
|
case NotificationUrgency.Critical:
|
|
return 0
|
|
case NotificationUrgency.Low:
|
|
return Math.min(maxPopupDuration, Math.max(lowPopupDuration, requestedDuration(expireTimeout)))
|
|
default:
|
|
return Math.min(maxPopupDuration, Math.max(normalPopupDuration, requestedDuration(expireTimeout)))
|
|
}
|
|
}
|
|
|
|
function requestedDuration(expireTimeout) {
|
|
// FreeDesktop notification spec (and Quickshell) report expireTimeout in
|
|
// milliseconds, so pass it through directly.
|
|
var ms = Number(expireTimeout || 0)
|
|
if (!isFinite(ms) || ms <= 0) return 0
|
|
return Math.round(ms)
|
|
}
|
|
|
|
// DND bypass: only let through notifications we trust to be intentional
|
|
// and rare.
|
|
// - omarchy-action: a user-action confirmation toast ("Theme changed",
|
|
// "Screenshot saved"). The user JUST did something — their feedback
|
|
// should show.
|
|
// - urgency=critical AND app_name=notify-send: bare-CLI emergency alerts.
|
|
// Trusted because it's almost always omarchy or system shell scripts —
|
|
// chat apps set app_name to their brand (Discord/Slack/Vesktop), which
|
|
// falls outside this rule.
|
|
function shouldBypassDnd(notification) {
|
|
return NotificationLogic.shouldBypassDnd(notification, NotificationUrgency.Critical)
|
|
}
|
|
|
|
function snapshotOf(notification) {
|
|
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
|
|
// captured for the popup card.
|
|
notification.tracked = true
|
|
var snapshot = snapshotOf(notification)
|
|
liveRefs[snapshot.originalId] = notification
|
|
// Guard the delete: a newer notification may have reused this originalId
|
|
// (freedesktop replaces_id) and taken over the map slot.
|
|
notification.closed.connect(function() {
|
|
if (service.liveRefs[snapshot.originalId] === notification)
|
|
delete service.liveRefs[snapshot.originalId]
|
|
})
|
|
|
|
// 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
|
|
}
|
|
|
|
persistPopupFile(snapshot)
|
|
// Qt.callLater avoids "QV4::Object::insertMember" crashes when a
|
|
// Repeater is mid-incubation while we mutate its model.
|
|
Qt.callLater(function() {
|
|
removePopupsByOriginalId(snapshot.originalId, NotificationLogic.popupFileName(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)]
|
|
}
|
|
|
|
// 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
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
function dismissPopup(index) {
|
|
removePopup(index, "dismiss")
|
|
}
|
|
|
|
function expirePopup(index) {
|
|
removePopup(index, "expire")
|
|
}
|
|
|
|
function removePopup(index, reason) {
|
|
if (index < 0 || index >= popupModel.count) return
|
|
var entry = popupModel.get(index)
|
|
var originalId = entry ? entry.originalId : -1
|
|
// 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 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) {
|
|
archivePopupFileFor(entry)
|
|
if (restored) delete restoredPopups[NotificationLogic.popupFileName(entry)]
|
|
}
|
|
popupModel.remove(index)
|
|
if (ref) {
|
|
try {
|
|
if (ref.tracked) {
|
|
if (reason === "expire" && typeof ref.expire === "function") ref.expire()
|
|
else ref.dismiss()
|
|
}
|
|
} catch (e) {
|
|
// Object already torn down by the server — nothing to dismiss.
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearPopups() {
|
|
while (popupModel.count > 0) dismissPopup(0)
|
|
}
|
|
|
|
// 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
|
|
// clients register a libnotify action under the canonical identifier
|
|
// "default" instead; that one only works while the sender is still live.
|
|
function invokePopupDefault(index) {
|
|
if (index < 0 || index >= popupModel.count) return
|
|
var entry = popupModel.get(index)
|
|
var command = entry ? String(entry.exec || "") : ""
|
|
if (command) {
|
|
// Detached so the launched command outlives the shell process, which the
|
|
// installer toasts depend on: they restart the shell as their first act.
|
|
Util.execDetached(command)
|
|
dismissPopup(index)
|
|
return
|
|
}
|
|
// 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
|
|
try {
|
|
if (ref && ref.actions) {
|
|
for (var i = 0; i < ref.actions.length; i++) {
|
|
var action = ref.actions[i]
|
|
if (action && action.identifier === "default") {
|
|
action.invoke()
|
|
invoked = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Notification already torn down by the server — fall through to focus.
|
|
console.warn("invoke default failed:", e)
|
|
}
|
|
// Chat apps (Slack, Discord, Vesktop, etc.) rarely register a "default"
|
|
// libnotify action — they just expect clicking the notification to
|
|
// focus their window. Fall back to focusing the sending app by class so
|
|
// that click-to-jump actually works.
|
|
if (!invoked) focusApp(entry)
|
|
dismissPopup(index)
|
|
}
|
|
|
|
// Try to focus an existing Hyprland window matching the notification's
|
|
// sender. The helper handles case-insensitive class matching.
|
|
function focusApp(entry) {
|
|
if (!entry || !entry.app) return
|
|
focusAppProc.command = [
|
|
service.omarchyPath + "/bin/omarchy-hyprland-focus-app",
|
|
String(entry.app)
|
|
]
|
|
focusAppProc.running = true
|
|
}
|
|
|
|
Process { id: focusAppProc; running: false }
|
|
|
|
Process {
|
|
id: ensureDirsProc
|
|
command: ["mkdir", "-p", service.stateDir, service.popupStateDir, service.historyDir]
|
|
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, 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
|
|
// 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)])
|
|
}
|
|
|
|
// ---------------------------------------------------- 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
|
|
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)) {
|
|
// 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
|
|
// 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)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------- settings persistence
|
|
|
|
FileView {
|
|
id: settingsFile
|
|
path: service.settingsPath
|
|
watchChanges: false
|
|
atomicWrites: true
|
|
printErrors: false
|
|
onLoaded: service.loadSettings(text())
|
|
// First-run: the file doesn't exist yet. Without this branch,
|
|
// `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: settingsSaveTimer
|
|
interval: 200
|
|
repeat: false
|
|
onTriggered: service.flushSettings()
|
|
}
|
|
|
|
function scheduleSettingsSave() {
|
|
if (!service.settingsLoaded) return
|
|
settingsSaveTimer.restart()
|
|
}
|
|
|
|
property bool settingsLoaded: false
|
|
|
|
function loadSettings(raw) {
|
|
// FileView can fire onLoaded more than once during startup — the implicit
|
|
// 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
|
|
persisted.doNotDisturb = parsed.dnd
|
|
service._hydrating = false
|
|
}
|
|
|
|
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 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 settings file. FileView
|
|
// surfaces an empty string when the file doesn't exist; loadSettings
|
|
// handles that path.
|
|
Qt.callLater(function() {
|
|
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
|
|
// 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
|
|
|
|
IpcHandler {
|
|
target: "notifications"
|
|
|
|
function dndState(): string {
|
|
return service.doNotDisturb ? "on" : "off"
|
|
}
|
|
|
|
function toggleDnd(): string {
|
|
service.setDoNotDisturb(!service.doNotDisturb)
|
|
return dndState()
|
|
}
|
|
|
|
function setDnd(value: string): string {
|
|
var v = String(value || "").toLowerCase()
|
|
var on = v === "true" || v === "1" || v === "on" || v === "yes"
|
|
service.setDoNotDisturb(on)
|
|
return dndState()
|
|
}
|
|
|
|
function isDnd(): string {
|
|
return dndState()
|
|
}
|
|
|
|
// Replay the notifications that have been moved into the history dir.
|
|
function showHistory(): string {
|
|
return service.showRecentHistory()
|
|
}
|
|
|
|
// `clear` forgets the recorded history; the toasts on screen stay put.
|
|
function clear(): string {
|
|
service.clearHistory()
|
|
return "ok"
|
|
}
|
|
|
|
function dismissAll(): string {
|
|
service.clearPopups()
|
|
return "ok"
|
|
}
|
|
|
|
// Dismiss the most recent popup.
|
|
function dismissOne(): string {
|
|
if (popupModel.count === 0) return "none"
|
|
service.dismissPopup(0)
|
|
return "ok"
|
|
}
|
|
|
|
// Fire the default action on the most recent popup, then dismiss it.
|
|
function invokeLast(): string {
|
|
if (popupModel.count === 0) return "none"
|
|
service.invokePopupDefault(0)
|
|
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
|
|
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
|
|
}
|
|
}
|
|
return hit ? "ok" : "none"
|
|
}
|
|
|
|
function ping(): string { return "ok" }
|
|
}
|
|
|
|
// ---------------------------------------------------- server
|
|
|
|
NotificationServer {
|
|
id: server
|
|
keepOnReload: false
|
|
imageSupported: true
|
|
actionsSupported: true
|
|
bodyMarkupSupported: true
|
|
bodyHyperlinksSupported: true
|
|
persistenceSupported: true
|
|
|
|
onNotification: function(notification) {
|
|
service.handleNotification(notification)
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------- popup UI
|
|
//
|
|
// One PanelWindow per output (Variants on Quickshell.screens) holding the
|
|
// stacked toast cards. Layer is Overlay, exclusionMode Ignore, no
|
|
// keyboard focus — popups are passive surfaces and must never steal input
|
|
// from the focused application.
|
|
|
|
Variants {
|
|
model: Quickshell.screens
|
|
|
|
PanelWindow {
|
|
id: popupWindow
|
|
required property var modelData
|
|
screen: modelData
|
|
visible: popupModel.count > 0
|
|
|
|
WlrLayershell.namespace: "omarchy-notifications"
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
|
exclusionMode: ExclusionMode.Ignore
|
|
color: "transparent"
|
|
|
|
readonly property var popupPlacement: NotificationLogic.popupPlacement(
|
|
service.barPosition, service.barClearance, Style.gapsOut)
|
|
|
|
// Full-screen, fixed-size surface (like the OSD overlay). Adding or
|
|
// removing a toast changes only the content inside; the Wayland surface
|
|
// never resizes, so the compositor can't briefly scale a stale buffer --
|
|
// which is what stretched/squished the cards during count changes.
|
|
anchors { top: true; bottom: true; left: true; right: true }
|
|
|
|
// Keep the surface click-through except over the toast column, so the
|
|
// rest of the (invisible) full-screen overlay never eats input.
|
|
mask: Region { item: popupColumn }
|
|
|
|
ColumnLayout {
|
|
id: popupColumn
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.topMargin: popupWindow.popupPlacement.margins.top
|
|
anchors.rightMargin: popupWindow.popupPlacement.margins.right
|
|
spacing: Style.space(8)
|
|
|
|
Repeater {
|
|
model: popupModel
|
|
|
|
// The delegate is a slot Item that owns lifetime timer state. The
|
|
// actual visuals live in NotificationCard, which the history panel
|
|
// also reuses.
|
|
delegate: Item {
|
|
id: cardSlot
|
|
required property int index
|
|
required property string app
|
|
required property string appIcon
|
|
required property string summary
|
|
required property string body
|
|
required property string image
|
|
required property string glyph
|
|
required property int urgency
|
|
required property double expireTimeout
|
|
required property double timestamp
|
|
|
|
// Each card sizes itself based on mode (text vs media); the slot
|
|
// tracks the card so the column auto-fits to whichever is widest.
|
|
Layout.preferredWidth: card.implicitWidth
|
|
Layout.alignment: Qt.AlignRight
|
|
implicitHeight: card.implicitHeight
|
|
|
|
readonly property real lifetime: service.durationFor(cardSlot.urgency, cardSlot.expireTimeout)
|
|
property real remainingLifetime: 1.0
|
|
readonly property bool ticking: cardSlot.lifetime > 0 && !card.hovered
|
|
|
|
Timer {
|
|
interval: 50
|
|
repeat: true
|
|
running: cardSlot.ticking
|
|
onTriggered: {
|
|
if (cardSlot.lifetime <= 0) return
|
|
cardSlot.remainingLifetime -= 50.0 / cardSlot.lifetime
|
|
if (cardSlot.remainingLifetime <= 0) {
|
|
cardSlot.remainingLifetime = 0
|
|
service.expirePopup(cardSlot.index)
|
|
}
|
|
}
|
|
}
|
|
|
|
NotificationCard {
|
|
id: card
|
|
anchors.right: parent.right
|
|
app: cardSlot.app
|
|
appIcon: cardSlot.appIcon
|
|
summary: cardSlot.summary
|
|
body: cardSlot.body
|
|
image: cardSlot.image
|
|
urgency: cardSlot.urgency
|
|
timestamp: cardSlot.timestamp
|
|
cornerRadius: service.cornerRadius
|
|
fontFamily: service.shell && service.shell.bar ? service.shell.bar.fontFamily : ""
|
|
glyph: cardSlot.glyph
|
|
|
|
onCloseRequested: service.dismissPopup(cardSlot.index)
|
|
onCardClicked: service.invokePopupDefault(cardSlot.index)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|