Files
Panama/config/dot/quickshell/services/Notifs.qml
T
Gabriel Brown 3d21e20041 Make every control tell the truth
The audit's first tier, in one change: every case found where the interface
asserted something the system did not do.

Twenty-one compositor-owned preferences -- the whole Mouse & Touchpad page,
plus window layout, snapping, dim-inactive and the magnifier -- had the live
half (hyprctl eval) and not the config-time half, so they quietly reverted on
every hyprctl reload. All 70 hypr-backed keys now have a prefs.get() in the
Lua, and hypr-prefs-contract pins both the presence and that the Lua fallback
equals the schema default, which is how the touchpad page misreported natural
scrolling on first boot.

The idle generator fell back from unwritten battery keys to the AC values
while the Power page displayed the schema defaults: a fresh laptop showed
"suspend at 20 minutes" and generated no suspend listener, then discharged to
zero in a bag. Unwritten keys now use the defaults the page shows
(idle-defaults-contract pins generator to schema; idle-config-contract
re-pinned to the new rule with the tradeoff recorded), and change-settings
enables managed idle on any machine with a battery -- without starting
hypridle in whatever session the installer runs under.

The per-app lock-screen notification switches wrote fields nothing read:
hyprlock cannot render notifications. Removed, with the rule model shrunk to
{enabled}, stale stored fields dropped at normalization, and the contract now
forbidding the page from growing lock-screen switches it cannot honor.

The battery warning thresholds were searchable, documented as "Found on
Power & Lock", and rendered nowhere -- and crossing the low threshold changed
only a glyph's color. Both sliders now exist where search was already sending
people, and low battery publishes a real notification at important priority.

Three handoffs opened GNOME panels that are inert in a Hyprland session. The
keyboard handoff is gone (that panel writes gsettings nothing here reads, and
the working controls sat on the same page); Connectivity gains a Wi-Fi row
that opens GNOME's actual Wi-Fi panel -- hidden SSIDs and 802.1X finally have
a road -- beside the network row that legitimately drives NetworkManager; the
universal-access handoff is gone, its few working toggles being controls this
app already owns. And the accessibility page now gives the true reason sticky
keys are missing: each Wayland compositor implements its own and Hyprland
does not yet -- not "an X11 feature with no Wayland equivalent," which sent
people to the wrong conclusion about the platform.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 11:43:39 -04:00

342 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 : {};
// Only `enabled` -- see the lock-screen note below for why the rule
// model carries nothing else, and drops old fields on the way through.
return { enabled: source.enabled !== 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
};
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;
}
// 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);
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);
}
}