From c087998710cdf9a1b011e25456acd2de17eadc88 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 05:38:39 -0400 Subject: [PATCH] feat: add notification application rules --- .../modules/settings/NotificationsPage.qml | 64 +++++++++++++ config/dot/quickshell/services/Notifs.qml | 93 ++++++++++++++++++ .../notification-app-rules-contract.sh | 94 +++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100755 tests/quickshell/notification-app-rules-contract.sh diff --git a/config/dot/quickshell/modules/settings/NotificationsPage.qml b/config/dot/quickshell/modules/settings/NotificationsPage.qml index 43163d4..08172aa 100644 --- a/config/dot/quickshell/modules/settings/NotificationsPage.qml +++ b/config/dot/quickshell/modules/settings/NotificationsPage.qml @@ -50,6 +50,70 @@ SettingsPage { SliderRow { setting: "maxVisibleToasts"; divider: false } } + SettingsCard { + title: "Application rules" + subtitle: "Apps appear here after they send a notification." + + TextRow { + visible: Notifs.applications.length === 0 + label: "No applications remembered yet" + detail: "Application controls will appear after the first notification arrives." + divider: false + } + + Repeater { + model: Notifs.applications + + Column { + required property var modelData + + readonly property var app: modelData + + width: parent.width + + SettingRow { + label: app.name + detail: app.id + controlWidth: 48 + + SettingsToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: Notifs.appRule(app.id).enabled + onToggled: value => Notifs.setAppRule(app.id, { enabled: value }) + } + } + + SettingRow { + label: "Show on lock screen" + detail: "Allow this app's notifications on the lock screen" + controlWidth: 48 + + SettingsToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: Notifs.appRule(app.id).showOnLockScreen + onToggled: value => Notifs.setAppRule(app.id, { showOnLockScreen: value }) + } + } + + SettingRow { + label: "Show content on lock screen" + detail: "Show message details when this app is visible there" + divider: false + controlWidth: 48 + + SettingsToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: Notifs.appRule(app.id).showContentOnLockScreen + onToggled: value => Notifs.setAppRule(app.id, { showContentOnLockScreen: value }) + } + } + } + } + } + SettingsCard { title: "Focus sessions" subtitle: "A focus session binds quiet mode and Caffeine to the current workspace." diff --git a/config/dot/quickshell/services/Notifs.qml b/config/dot/quickshell/services/Notifs.qml index 5fc4943..e383384 100644 --- a/config/dot/quickshell/services/Notifs.qml +++ b/config/dot/quickshell/services/Notifs.qml @@ -37,6 +37,31 @@ Singleton { // 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 : {}; + } + + readonly property var appRules: Object.assign({}, root.persistedAppRules, root.fallbackAppRules) + + readonly property var applications: { + const remembered = root.rememberedApplications; + return Object.keys(root.appRules).map(appId => ({ + id: appId, + name: remembered[appId]?.name || appId + })).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. @@ -49,6 +74,70 @@ Singleton { readonly property bool hasNotifications: root.history.length > 0 + function notificationAppId(notification: Notification): string { + const desktopEntry = String(notification.desktopEntry ?? "").trim(); + return desktopEntry || String(notification.appName ?? "").trim() || "Notifications"; + } + + 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 (DesktopPreferences.set("notificationAppRules", next)) + root.fallbackAppRules = {}; + else + root.fallbackAppRules = next; + return true; + } + + function rememberApplication(notification: Notification): 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: Notification): bool { + const rule = root.appRule(root.notificationAppId(notification)); + return rule.enabled && rule.showOnLockScreen; + } + + function shouldShowContentOnLockScreen(notification: Notification): 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 // NotificationCenter.qml renders directly. readonly property var groups: { @@ -92,6 +181,10 @@ Singleton { 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(); diff --git a/tests/quickshell/notification-app-rules-contract.sh b/tests/quickshell/notification-app-rules-contract.sh new file mode 100755 index 0000000..93ef26d --- /dev/null +++ b/tests/quickshell/notification-app-rules-contract.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +service="$repo_dir/config/dot/quickshell/services/Notifs.qml" +page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml" + +fail() { + printf 'notification application rules contract: %s\n' "$1" >&2 + exit 1 +} + +[[ -f "$service" ]] || fail 'notification service is missing' +[[ -f "$page" ]] || fail 'notification settings page is missing' + +SERVICE_PATH="$service" PAGE_PATH="$page" bun -e ' +const source = await Bun.file(process.env.SERVICE_PATH).text(); +const page = await Bun.file(process.env.PAGE_PATH).text(); + +function fail(message) { + console.error(`notification application rules contract: ${message}`); + process.exit(1); +} + +function functionBody(name) { + const start = source.indexOf(`function ${name}(`); + if (start === -1) + fail(`missing ${name}()`); + const open = source.indexOf("{", start); + let depth = 0; + for (let index = open; index < source.length; index++) { + if (source[index] === "{") depth++; + if (source[index] === "}" && --depth === 0) + return source.slice(open + 1, index); + } + fail(`${name}() is unterminated`); +} + +const notificationAppId = Function("notification", functionBody("notificationAppId")); +const normalizedAppRule = Function("rule", functionBody("normalizedAppRule")); + +const identityFixtures = [ + { notification: { desktopEntry: "org.signal.Signal.desktop", appName: "Signal" }, expected: "org.signal.Signal.desktop" }, + { notification: { desktopEntry: "", appName: "Terminal" }, expected: "Terminal" }, + { notification: { desktopEntry: "", appName: "" }, expected: "Notifications" } +]; +for (const fixture of identityFixtures) { + const actual = notificationAppId(fixture.notification); + if (actual !== fixture.expected) + fail(`stable app identity expected ${fixture.expected}, got ${actual}`); +} + +const defaultRule = normalizedAppRule({}); +if (JSON.stringify(defaultRule) !== JSON.stringify({ enabled: true, showOnLockScreen: true, showContentOnLockScreen: true })) + fail(`missing rule fields did not default safely: ${JSON.stringify(defaultRule)}`); + +const explicitRule = normalizedAppRule({ enabled: false, showOnLockScreen: false, showContentOnLockScreen: false }); +if (JSON.stringify(explicitRule) !== JSON.stringify({ enabled: false, showOnLockScreen: false, showContentOnLockScreen: false })) + fail(`explicit rule was not preserved: ${JSON.stringify(explicitRule)}`); + +for (const required of ["rememberApplication", "appRule", "setAppRule", "shouldShowOnLockScreen", "shouldShowContentOnLockScreen"]) { + functionBody(required); +} + +const arrival = source.indexOf("onNotification: notification =>"); +const tracked = source.indexOf("notification.tracked = true", arrival); +const muted = source.indexOf("!root.appRule(appId).enabled", arrival); +if (arrival === -1 || tracked === -1 || muted === -1 || muted > tracked) + fail("muted applications are not rejected before tracking/history/unread/toast work"); +if (!source.includes("DesktopPreferences.get(\"notificationAppRules\")")) + fail("rules are not read through DesktopPreferences"); +if (!source.includes("DesktopPreferences.set(\"notificationAppRules\", next)")) + fail("rules are not written through DesktopPreferences"); +if (!source.includes("next[knownAppId] = root.appRule(knownAppId)")) + fail("persisted rules are not normalized to the required three-field shape"); +if (!source.includes("root.fallbackAppRules = next")) + fail("missing-schema preference writes do not retain an in-memory fallback"); +if (!source.includes("if (!root.doNotDisturb)")) + fail("global DND popup override was removed"); + +for (const required of [ + "Notifs.applications", + "Notifs.appRule(app.id).enabled", + "showOnLockScreen", + "showContentOnLockScreen", + "Notifs.setAppRule" +]) { + if (!page.includes(required)) + fail(`settings page is missing ${required}`); +} + +console.log("notification application rules contract: PASS"); +'