A Process's exited and streamFinished signals aren't guaranteed to fire in order, and several services decided an outcome on whichever fired first: KdeConnect could report a successful file transfer as failed if exited landed before the real stdout payload; Clipboard could present a failed history query as an empty-but-healthy one; Brightness could strand the last queued write of a drag; SoundFeedback and SystemLocale could drop or misapply a rapid second toggle/click because re-arming an already-running Process is a no-op. All five now wait for both signals and let the authoritative one decide, matching the pattern HomeAssistantConfig.qml already used correctly. Health's "copy report" never enabled stdin, so it copied nothing while claiming success. Capture announced every recording as saved regardless of the recorder's actual exit code. Connectivity never restarted Bluetooth discovery when the adapter was enabled from an already-open page. CalendarAgenda left the UI in "loading" forever if its helper died at startup, and the helper itself could crash unguarded instead of reporting unavailable. Geocoding silently dropped a query typed while the previous one was still in flight. Notifs leaked tracked-but-undisplayed notifications under Do Not Disturb, and dismissAll() skipped them. Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
346 lines
14 KiB
QML
346 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 behaviour,
|
|
// 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 centre 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;
|
|
}
|
|
|
|
if (!root.doNotDisturb) {
|
|
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);
|
|
}
|
|
}
|