The mode data model shipped with an allow list and nothing that read it. The summary would say "2 apps may interrupt" while notification delivery never consulted the list and no editor could set it. That is the dead row this work has spent its time removing, introduced by the work itself. The banner gate consults the mode in force now, and the list can be edited from the applications that have actually sent a notification -- an exception for something that never notifies is not a choice worth offering. Exceptions belong to a mode. allowedApps is empty whenever no mode is active, so a Do Not Disturb switched on by hand stays absolute and nothing can leak into it. That scoping is asserted, not just written. Verifying this took three attempts, and the second was a real defect in the guard rather than in the code. The contract grep for FocusModes.allows matched the comment that explains it, so the check passed with the enforcement deleted. It matches the gate expression now. A guard a comment can satisfy is not a guard, and this is the third time prose has satisfied one here. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
349 lines
14 KiB
QML
349 lines
14 KiB
QML
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.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)
|
|
|
|
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 => ({
|
|
id: appId,
|
|
name: root.applicationLabel(appId, entries, remembered)
|
|
})).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.
|
|
function notificationTimeoutMs(notification: var): int {
|
|
if (notification.urgency === 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";
|
|
}
|
|
|
|
function applicationLabel(appId: string, entries: var, remembered: var): string {
|
|
const desktopId = appId.endsWith(".desktop") ? appId.slice(0, -8) : appId;
|
|
const entry = DesktopEntries.byId(appId)
|
|
|| DesktopEntries.byId(desktopId)
|
|
|| DesktopEntries.heuristicLookup(appId)
|
|
|| DesktopEntries.heuristicLookup(desktopId);
|
|
return entry?.name || remembered[appId]?.name || appId;
|
|
}
|
|
|
|
function normalizedAppRule(rule: var): var {
|
|
const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {};
|
|
return {
|
|
enabled: source.enabled !== false,
|
|
showOnLockScreen: source.showOnLockScreen !== false,
|
|
showContentOnLockScreen: source.showContentOnLockScreen !== false
|
|
};
|
|
}
|
|
|
|
function appRule(appId: string): var {
|
|
return root.normalizedAppRule(root.appRules[appId]);
|
|
}
|
|
|
|
function setAppRule(appId: string, patch: var): bool {
|
|
if (!appId)
|
|
return false;
|
|
|
|
const current = root.appRule(appId);
|
|
const next = {};
|
|
for (const knownAppId of Object.keys(root.appRules))
|
|
next[knownAppId] = root.appRule(knownAppId);
|
|
next[appId] = {
|
|
enabled: patch.enabled === undefined ? current.enabled : patch.enabled === true,
|
|
showOnLockScreen: patch.showOnLockScreen === undefined ? current.showOnLockScreen : patch.showOnLockScreen === true,
|
|
showContentOnLockScreen: patch.showContentOnLockScreen === undefined ? current.showContentOnLockScreen : patch.showContentOnLockScreen === true
|
|
};
|
|
|
|
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
|
|
root.fallbackAppRules = {};
|
|
else
|
|
root.fallbackAppRules = next;
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
|
|
if (root.appRules[appId] === undefined)
|
|
root.setAppRule(appId, {});
|
|
return appId;
|
|
}
|
|
|
|
// These policy getters deliberately accept Notification objects, so a lock
|
|
// screen can use the same source of truth without duplicating app matching.
|
|
function shouldShowOnLockScreen(notification: var): bool {
|
|
const rule = root.appRule(root.notificationAppId(notification));
|
|
return rule.enabled && rule.showOnLockScreen;
|
|
}
|
|
|
|
function shouldShowContentOnLockScreen(notification: var): bool {
|
|
const rule = root.appRule(root.notificationAppId(notification));
|
|
return rule.enabled && rule.showOnLockScreen && rule.showContentOnLockScreen;
|
|
}
|
|
|
|
// 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);
|
|
if (!root.appRule(appId).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;
|
|
}
|
|
|
|
// 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 (!root.doNotDisturb || FocusModes.allows(root.notificationAppId(notification))) {
|
|
root.popups = [notification].concat(root.popups);
|
|
} 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);
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|