pragma Singleton // ───────────────────────────────────────────────────────────────────────────── // The freedesktop notification server, plus the two lists the UI renders: // // popups — what Toasts.qml is currently showing (transient, timed) // history — GNOME's message tray, what NotificationList.qml shows // // A notification lives exactly as long as `tracked` is true, so history holds // the *live* objects rather than copies: that keeps actions and inline replies // working from the tray, which is what GNOME does. The cost is that a // notification an app closes itself (progress bars, "download finished" // replacing "downloading") disappears from history too — correct behavior, // but the reason history is not append-only. // ───────────────────────────────────────────────────────────────────────────── import Quickshell import Quickshell.Io import Quickshell.Services.Notifications import QtQuick import qs.config Singleton { id: root readonly property alias server: server // Live tracked set, straight from the server. Mostly useful for counting; // the UI wants `popups` / `history`, which are ordered newest-first. readonly property alias active: server.trackedNotifications property var history: [] property var popups: [] // Suppresses toasts entirely. Notifications still reach history. property bool doNotDisturb: false // Cleared when the notification center is opened. The bar binds to this. property int unreadCount: 0 // Kept separate from the persisted map so this version can safely run // before the matching schema entry lands. A later accepted write folds the // complete map into DesktopPreferences and clears this fallback. property var fallbackAppRules: ({}) // Display metadata is intentionally session-only. The durable shape stays // just the per-application rule map, while a fresh notification gives the // settings page a human-readable name straight away. property var rememberedApplications: ({}) readonly property var persistedAppRules: { const stored = DesktopPreferences.get("notificationAppRules"); return stored && typeof stored === "object" && !Array.isArray(stored) ? stored : {}; } // The schema change is the persistence boundary. This branch keeps a // session fallback only so it remains usable while that companion change // is being integrated; it intentionally makes no restart guarantee then. readonly property bool appRulesSchemaAvailable: PreferenceSchema.has("notificationAppRules") readonly property var appRules: Object.assign({}, root.persistedAppRules, root.fallbackAppRules) // Every application that has a rule, with everything the settings list // needs to draw a row: the live-resolved name, the cached icon, when it // last said something, and the rule itself. Sorted by name, because the // page decides its own sections (recent / customized / all) from the // fields rather than from this order. readonly property var applications: { // byId()/heuristicLookup() do not make a binding by themselves. This // read updates persisted app labels once DesktopEntries finishes scan. const entries = DesktopEntries.applications.values; const remembered = root.rememberedApplications; return Object.keys(root.appRules).map(appId => { const rule = root.appRule(appId); return { id: appId, name: root.applicationLabel(appId, entries, remembered), icon: root.applicationIcon(appId, rule.icon), lastSeenMs: rule.lastSeenMs, rule: rule }; }).sort((a, b) => a.name.localeCompare(b.name)); } // Arrival times, keyed by notification id — the protocol carries no // timestamp. Deliberately formatted once at arrival rather than shown as // "5 minutes ago", which would need a clock ticking behind every card. readonly property var arrivals: ({}) function timeText(id: int): string { const at = root.arrivals[id]; return at ? Qt.formatDateTime(at, Settings.use24Hour ? "HH:mm" : "h:mm AP") : ""; } // Freedesktop timeout resolution, shared by the toast countdown (Toast.qml) // and the no-display expiry a transient notification gets while Do Not // Disturb is on (below). Critical urgency and an explicit expireTimeout // override policy; -1 ("server decides") falls back to it. 0 means "never // auto-expire" per spec. // Reads the effective urgency, so an application told to be treated as // critical also gets the critical banner duration rather than only the // critical look. function notificationTimeoutMs(notification: var): int { if (root.effectiveUrgency(notification) === NotificationUrgency.Critical) return Settings.notificationTimeoutCriticalMs; if (notification.expireTimeout === 0) return 0; if (notification.expireTimeout > 0) return Math.round(notification.expireTimeout * 1000); return Settings.notificationTimeoutMs; } readonly property bool hasNotifications: root.history.length > 0 function notificationAppId(notification: var): string { const desktopEntry = String(notification.desktopEntry ?? "").trim(); return desktopEntry || String(notification.appName ?? "").trim() || "Notifications"; } // The installed application behind a rule key, or null. An appId is either // a desktop entry id or the raw name an application announced itself with, // so both spellings and the heuristic are tried. function desktopEntryFor(appId: string): var { const desktopId = appId.endsWith(".desktop") ? appId.slice(0, -8) : appId; return DesktopEntries.byId(appId) || DesktopEntries.byId(desktopId) || DesktopEntries.heuristicLookup(appId) || DesktopEntries.heuristicLookup(desktopId); } // Live first, cache second: an installed application is named by its // desktop entry every time, and the name stored on the rule only stands in // for one that is gone, sandboxed, or not scanned yet. The other way round // would freeze a name at the moment it was first heard from. function applicationLabel(appId: string, entries: var, remembered: var): string { return root.desktopEntryFor(appId)?.name || remembered[appId]?.name || appId; } // Same rule for the icon: the desktop entry wins, the cached icon (an // entry icon, or the appIcon the notification carried) is the fallback. function applicationIcon(appId: string, cached: string): string { return root.desktopEntryFor(appId)?.icon || String(cached ?? ""); } // The durable per-application rule. Everything past `enabled` is optional // so rules written when this held only `enabled` still load, and anything // unrecognized -- including the lock-screen pair described below -- is // dropped rather than carried forward as if something read it. // // enabled off rejects the notification before anything else happens // sound false silences the bell for this application only // display "history" files it without a banner and without a bell // urgency "low"/"critical" override what the application claims // lastSeenMs when it last notified, stamped by rememberApplication // name, icon cached labels for the settings list (see above: fallback) function normalizedAppRule(rule: var): var { const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {}; const display = String(source.display ?? ""); const urgency = String(source.urgency ?? ""); const lastSeen = Number(source.lastSeenMs); return { enabled: source.enabled !== false, sound: source.sound !== false, display: display === "history" ? "history" : "banners", urgency: urgency === "low" || urgency === "critical" ? urgency : "auto", lastSeenMs: isFinite(lastSeen) && lastSeen > 0 ? Math.round(lastSeen) : 0, name: String(source.name ?? ""), icon: String(source.icon ?? "") }; } // A rule with no defaults touched. Everything the page calls "customized" // is a field that differs from this. readonly property var defaultAppRule: root.normalizedAppRule({}) function appRule(appId: string): var { return root.normalizedAppRule(root.appRules[appId]); } // Has this application been given a rule that actually says something? The // bookkeeping fields do not count -- an application is "customized" // because of a decision somebody made about it, not because it notified. function isCustomized(appId: string): bool { const rule = root.appRule(appId); return rule.enabled !== true || rule.sound !== true || rule.display !== "banners" || rule.urgency !== "auto"; } function setAppRule(appId: string, patch: var): bool { if (!appId) return false; const changes = patch && typeof patch === "object" ? patch : {}; const merged = Object.assign({}, root.appRule(appId)); // Field by field rather than a blind merge, so a patch cannot smuggle // a value of the wrong type (or an unknown key) into the stored rule. if (changes.enabled !== undefined) merged.enabled = changes.enabled === true; if (changes.sound !== undefined) merged.sound = changes.sound === true; if (changes.display !== undefined) merged.display = String(changes.display); if (changes.urgency !== undefined) merged.urgency = String(changes.urgency); if (changes.lastSeenMs !== undefined) merged.lastSeenMs = Number(changes.lastSeenMs); if (changes.name !== undefined) merged.name = String(changes.name); if (changes.icon !== undefined) merged.icon = String(changes.icon); const next = {}; for (const knownAppId of Object.keys(root.appRules)) next[knownAppId] = root.appRule(knownAppId); next[appId] = root.normalizedAppRule(merged); if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next)) root.fallbackAppRules = {}; else root.fallbackAppRules = next; return true; } // Delete the rule outright, rather than resetting its fields: an // application with no rule is treated as permissive, so forgetting one is // undone by its next notification, which writes a fresh default rule. // The session-only remembered name goes with it, otherwise the row would // linger under a name nothing is keeping. function forgetApp(appId: string): bool { if (!appId || root.appRules[appId] === undefined) return false; const next = {}; for (const knownAppId of Object.keys(root.appRules)) { if (knownAppId !== appId) next[knownAppId] = root.appRule(knownAppId); } const remembered = Object.assign({}, root.rememberedApplications); delete remembered[appId]; root.rememberedApplications = remembered; // The session fallback is a whole-map replacement, so a deletion only // truly lands once the preference write succeeds -- which it does // whenever the schema key exists, i.e. always outside the transitional // build this fallback was written for. if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next)) root.fallbackAppRules = {}; else root.fallbackAppRules = next; return true; } // What this notification counts as after the application's override: the // urgency the sender claimed unless a rule disagrees with it. Read by the // bell, the banner timeout, the Do Not Disturb breakthrough gate, and the // card's critical edge, so all four agree on one answer. function effectiveUrgency(notification: var): var { const rule = root.appRule(root.notificationAppId(notification)); if (rule.urgency === "low") return NotificationUrgency.Low; if (rule.urgency === "critical") return NotificationUrgency.Critical; return notification.urgency; } function rememberApplication(notification: var): string { const appId = root.notificationAppId(notification); const next = Object.assign({}, root.rememberedApplications); next[appId] = { name: String(notification.appName ?? "").trim() || appId }; root.rememberedApplications = next; // Stamped alongside the labels the settings list falls back to when // the application is not installed here. Coalesced to one write per // minute per app: every setAppRule bumps the preferences revision and // restarts the settings.json write debounce, so stamping a chat // burst's every message would churn the whole preference store for a // timestamp nobody reads at that resolution. const rule = root.appRule(appId); const name = root.applicationLabel(appId, DesktopEntries.applications.values, next); const icon = root.applicationIcon(appId, String(notification.appIcon ?? "")); if (Date.now() - rule.lastSeenMs >= 60000 || rule.name !== name || rule.icon !== icon) { root.setAppRule(appId, { lastSeenMs: Date.now(), name: name, icon: icon }); } return appId; } // There are deliberately no lock-screen policy getters here, and no // lock-screen switches on the Notifications page. The lock screen is // hyprlock, which cannot render notifications, so two per-app privacy // toggles shipped for a while that controlled nothing -- on the page a // person checks precisely when they care. Stored rules may still carry // showOnLockScreen fields from that era; nothing reads them. If a lock // screen that can render notifications ever exists, the rule store and // appRule() are the right place to hang its policy back onto. // history grouped by app, in most-recent-app-first order — the shape // NotificationList.qml renders directly. readonly property var groups: { const out = []; const byApp = {}; for (const n of root.history) { const key = n.appName || "Notifications"; let group = byApp[key]; if (!group) { group = { app: key, icon: n.appIcon, desktopEntry: n.desktopEntry, items: [] }; byApp[key] = group; out.push(group); } group.items.push(n); } return out; } NotificationServer { id: server keepOnReload: true persistenceSupported: true bodySupported: true bodyMarkupSupported: true bodyImagesSupported: true imageSupported: true actionsSupported: true actionIconsSupported: true inlineReplySupported: true onNotification: notification => root.handleNotification(notification) } function handleNotification(notification: var): void { // Replayed from before a shell reload. Letting these through would // re-toast and re-list everything on every edit, so they are left // untracked and allowed to die. if (notification.lastGeneration) return; const appId = root.rememberApplication(notification); // Read once: the same rule decides rejection, the bell, and // whether this is allowed to be a banner at all. const rule = root.appRule(appId); if (!rule.enabled) return; // Without this the object is destroyed the instant this returns. notification.tracked = true; root.arrivals[notification.id] = new Date(); // The object may go away at any time (app-side close, dismiss()). // Drop our references synchronously when it does. notification.closed.connect(() => root.forget(notification)); // `transient` is the volume-OSD case: show it, never file it. if (!notification.transient) { root.pushHistory(notification); root.unreadCount += 1; } // "History only" is the quiet filing cabinet: it was added to // history and counted as unread above, and that is all it gets -- // no banner, and (never reaching playBell) no sound either. const historyOnly = rule.display === "history"; // The one exception to Do Not Disturb that is not a focus mode's, // and it is off unless somebody turned it on. It reads the // effective urgency, so a per-application override decides who // gets to claim "critical" rather than the sender alone. const breaksThrough = Settings.criticalBreaksThrough && root.effectiveUrgency(notification) === NotificationUrgency.Critical; // An exception belongs to the focus mode that is in force. A Do Not // Disturb switched on by hand has no exceptions and stays absolute, // because FocusModes.allows is false whenever no mode is active. if (historyOnly) { // Transient notifications are filed nowhere, so a hidden one // still needs the release its popup timeout would have given. if (notification.transient) root.scheduleTransientExpiry(notification); } else if (!root.doNotDisturb || FocusModes.allows(appId) || breaksThrough) { root.popups = [notification].concat(root.popups); root.playBell(notification); } else if (notification.transient) { // Never shown, and (being transient) never filed in history // either — nothing will otherwise dismiss() it, so schedule // the same release its popup timeout would have given it. root.scheduleTransientExpiry(notification); } } // ── The chime ─────────────────────────────────────────────────────────── // // A notification you can hear. GTK applications get this from libcanberra // for free; Panama's own popups had no sound at all, so a notification that // arrived while you were looking elsewhere simply did not happen. Same // theme, same desktop preference: one switch covers the whole session // rather than leaving Panama as the one thing that stays quiet -- or the // one thing that will not shut up. // // Throttled to one bell a second. A burst -- a chat catching up after a // suspend, ten build jobs finishing together -- would otherwise stack a // dozen overlapping bells, which is a noise rather than a notification. property real lastBellAt: 0 // Emitted for every notification that is bell-eligible, whether or not a // bell is actually audible. modules/notifications/VisualBell.qml listens. signal bellEligible(var notification) // Would this notification ring the bell, setting aside whether sound is // switched on at all? // // THE PINNED RULE, because it is the whole point of visual alerts: the // flash follows this predicate, and the bell follows this predicate AND // SoundFeedback.eventSounds. Every gate below is shared -- an application // you silenced stays silent both ways, a low-urgency notification stays // quiet both ways, and an application that says it played its own sound is // taken at its word both ways -- but the event-sounds switch is NOT. // // Gating the flash on event sounds would make Visual Alerts do nothing for // exactly the person it exists for: somebody who cannot hear the bell has // no reason to have event sounds on, and would turn on a switch that stays // dark. The flash is not a picture of the bell; it is the same alert in the // sense the person can receive. // // Sound RESOLUTION is deliberately not part of this. playBell gives up when // it cannot find a file to play, which is a fact about the sound theme on // disk; losing the flash because a theme is missing an ogg would be absurd. function bellWouldRing(notification: var): bool { // The per-application sound switch. Narrower than turning the // application off: its notifications still arrive and still show, they // just stop making noise. if (!root.appRule(root.notificationAppId(notification)).sound) return false; // Low urgency is the "you did not need to know this" tier -- battery // reaching full, a sync completing. It stays silent by design, and it // is the effective urgency, so "treat as low" is a way to keep an // application audible in principle but quiet in practice. if (root.effectiveUrgency(notification) === NotificationUrgency.Low) return false; // The freedesktop sound hint. This is the fix for the double chime: an // application that plays its own sound sets suppress-sound so the // notification server stays quiet, and Panama ignoring it meant one // notification made two noises a beat apart. const hints = notification.hints ?? {}; if (hints["suppress-sound"] === true) return false; return true; } function playBell(notification: var): void { if (!root.bellWouldRing(notification)) return; // Announced BEFORE the event-sounds gate, on purpose. See the rule // pinned above bellWouldRing. root.bellEligible(notification); if (!SoundFeedback.eventSounds) return; // The remaining two sound hints say what to play instead of the theme // bell -- sound-file is an absolute path the application supplies, // sound-name is a theme sound resolved through the same chain the bell // uses. Neither can make a notification ineligible, so neither is part // of the predicate above. const hints = notification.hints ?? {}; const soundFile = String(hints["sound-file"] ?? ""); const soundName = String(hints["sound-name"] ?? ""); const candidates = soundFile.startsWith("/") ? [soundFile] : (soundName !== "" ? SoundFeedback.soundCandidates(soundName) : SoundFeedback.bellCandidates); // A hint naming something unresolvable is a request for that sound, not // a request for the bell -- substituting would be a lie about which // notification arrived. if (candidates.length === 0) return; const now = Date.now(); if (bell.running || now - root.lastBellAt < 1000) return; root.lastBellAt = now; bell.command = SoundFeedback.playCommand(candidates); bell.running = true; } Process { id: bell } // Runs a DND-hidden transient notification through the same lifetime it // would have gotten as a visible popup (Toast.qml's countdown), just // without ever showing it, so it still gets released instead of staying // tracked forever. function scheduleTransientExpiry(notification: var): void { const ms = root.notificationTimeoutMs(notification); if (ms <= 0) return; const timer = Qt.createQmlObject("import QtQuick; Timer { repeat: false }", root); timer.interval = ms; timer.triggered.connect(() => { timer.destroy(); root.releaseTransient(notification); }); // Closed some other way first (app-side close, dismissAll()) — cancel // the pending timer instead of firing a stale dismiss() later. notification.closed.connect(() => timer.destroy()); timer.running = true; } // Transient notifications are never filed in history, so nothing else // holds a reference once their lifetime ends — release tracked state // directly. Shared by the DND-hidden expiry above and dismissAll() below. function releaseTransient(n: var): void { if (n.transient) n.dismiss(); } // ── Mutation ──────────────────────────────────────────────────────────── function pushHistory(n: var): void { const next = [n].concat(root.history); // Anything past the cap is released, otherwise it stays tracked // forever and the server's set grows without bound. const evicted = next.splice(Settings.notificationHistoryLimit); root.history = next; for (const old of evicted) old.dismiss(); } // Hide a toast without filing the notification away as read. Mirrors // GNOME: closing a banner leaves it in the tray. function dropPopup(n: Notification): void { const next = root.popups.filter(x => x !== n); if (next.length === root.popups.length) return; root.popups = next; root.releaseTransient(n); } function dismiss(n: Notification): void { n.dismiss(); } function dismissAll(): void { // Copy first: dismiss() re-enters through forget() and rewrites both // lists while we iterate. const all = root.history.slice(); // Transient notifications are never filed in history — whether // currently shown as a popup or hidden by Do Not Disturb with a // pending scheduleTransientExpiry() timer — so releasing them needs // its own pass over the server's full tracked set. const transients = root.active.values.filter(n => n.transient); root.history = []; root.popups = []; for (const n of all) n.dismiss(); for (const n of transients) root.releaseTransient(n); root.unreadCount = 0; } function dismissApp(appName: string): void { const doomed = root.history.filter(n => (n.appName || "Notifications") === appName); root.history = root.history.filter(n => doomed.indexOf(n) === -1); root.popups = root.popups.filter(n => doomed.indexOf(n) === -1); for (const n of doomed) n.dismiss(); } function markAllRead(): void { root.unreadCount = 0; } // Called from the `closed` signal — the object is on its way out, so this // only ever removes references, never touches the notification. function forget(n: var): void { delete root.arrivals[n.id]; if (root.history.indexOf(n) !== -1) root.history = root.history.filter(x => x !== n); if (root.popups.indexOf(n) !== -1) root.popups = root.popups.filter(x => x !== n); } }