From c087998710cdf9a1b011e25456acd2de17eadc88 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 05:38:39 -0400 Subject: [PATCH 1/3] 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"); +' From c415c4f1766366ad9f3c295e46c93df65eb1917b Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 05:51:32 -0400 Subject: [PATCH 2/3] fix: harden notification application rule integration --- config/dot/quickshell/services/Notifs.qml | 35 ++++-- .../NotificationAppRulesHarness.qml | 113 +++++++++++++++++ .../notification-app-rules-contract.sh | 119 +++++++++++++++++- 3 files changed, 252 insertions(+), 15 deletions(-) create mode 100644 tests/quickshell/NotificationAppRulesHarness.qml diff --git a/config/dot/quickshell/services/Notifs.qml b/config/dot/quickshell/services/Notifs.qml index e383384..a4cf51f 100644 --- a/config/dot/quickshell/services/Notifs.qml +++ b/config/dot/quickshell/services/Notifs.qml @@ -52,13 +52,21 @@ Singleton { 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: remembered[appId]?.name || appId + name: root.applicationLabel(appId, entries, remembered) })).sort((a, b) => a.name.localeCompare(b.name)); } @@ -74,11 +82,16 @@ Singleton { readonly property bool hasNotifications: root.history.length > 0 - function notificationAppId(notification: Notification): string { + 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 entry = DesktopEntries.byId(appId) || DesktopEntries.heuristicLookup(appId); + return entry?.name || remembered[appId]?.name || appId; + } + function normalizedAppRule(rule: var): var { const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {}; return { @@ -106,14 +119,14 @@ Singleton { showContentOnLockScreen: patch.showContentOnLockScreen === undefined ? current.showContentOnLockScreen : patch.showContentOnLockScreen === true }; - if (DesktopPreferences.set("notificationAppRules", next)) + if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next)) root.fallbackAppRules = {}; else root.fallbackAppRules = next; return true; } - function rememberApplication(notification: Notification): string { + function rememberApplication(notification: var): string { const appId = root.notificationAppId(notification); const next = Object.assign({}, root.rememberedApplications); next[appId] = { @@ -128,12 +141,12 @@ Singleton { // 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 { + function shouldShowOnLockScreen(notification: var): bool { const rule = root.appRule(root.notificationAppId(notification)); return rule.enabled && rule.showOnLockScreen; } - function shouldShowContentOnLockScreen(notification: Notification): bool { + function shouldShowContentOnLockScreen(notification: var): bool { const rule = root.appRule(root.notificationAppId(notification)); return rule.enabled && rule.showOnLockScreen && rule.showContentOnLockScreen; } @@ -174,7 +187,10 @@ Singleton { actionIconsSupported: true inlineReplySupported: true - onNotification: notification => { + 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. @@ -201,12 +217,11 @@ Singleton { if (!root.doNotDisturb) root.popups = [notification].concat(root.popups); - } } // ── Mutation ──────────────────────────────────────────────────────────── - function pushHistory(n: Notification): void { + function pushHistory(n: var): void { const next = [n].concat(root.history); // Anything past the cap is released, otherwise it stays tracked @@ -259,7 +274,7 @@ Singleton { // 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: Notification): void { + function forget(n: var): void { delete root.arrivals[n.id]; if (root.history.indexOf(n) !== -1) root.history = root.history.filter(x => x !== n); diff --git a/tests/quickshell/NotificationAppRulesHarness.qml b/tests/quickshell/NotificationAppRulesHarness.qml new file mode 100644 index 0000000..f4bdf8f --- /dev/null +++ b/tests/quickshell/NotificationAppRulesHarness.qml @@ -0,0 +1,113 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.config +import qs.services + +ShellRoot { + id: root + + function notification(idValue: int, desktopEntryValue: string, appNameValue: string): var { + const closeHandlers = []; + return { + id: idValue, + desktopEntry: desktopEntryValue, + appName: appNameValue, + appIcon: "", + transient: false, + lastGeneration: false, + tracked: false, + dismissed: false, + closed: { + connect: callback => closeHandlers.push(callback) + }, + dismiss: function() { + this.dismissed = true; + for (const callback of closeHandlers) + callback(); + } + }; + } + + function resetNotifications(): void { + Notifs.history = []; + Notifs.popups = []; + Notifs.unreadCount = 0; + Notifs.doNotDisturb = false; + } + + function reset(): void { + root.resetNotifications(); + Notifs.fallbackAppRules = {}; + Notifs.rememberedApplications = {}; + DesktopPreferences.set("notificationAppRules", {}); + } + + IpcHandler { + target: "notification-app-rules-test" + + function exercise(): string { + root.reset(); + + const signal = root.notification(1, "org.signal.Signal.desktop", "Signal"); + Notifs.handleNotification(signal); + const appId = Notifs.notificationAppId(signal); + const initialRules = DesktopPreferences.get("notificationAppRules"); + + root.resetNotifications(); + Notifs.setAppRule(appId, { enabled: false }); + const muted = root.notification(2, "org.signal.Signal.desktop", "Signal"); + Notifs.handleNotification(muted); + const mutedResult = { + tracked: muted.tracked, + history: Notifs.history.length, + popups: Notifs.popups.length, + unread: Notifs.unreadCount + }; + + root.reset(); + Notifs.doNotDisturb = true; + const dnd = root.notification(3, "org.signal.Signal.desktop", "Signal"); + Notifs.handleNotification(dnd); + + Notifs.setAppRule("org.privacy.App.desktop", { + showOnLockScreen: false, + showContentOnLockScreen: false + }); + const privateNotification = root.notification(4, "org.privacy.App.desktop", "Private"); + + return JSON.stringify({ + appId: appId, + initialRules: initialRules, + muted: mutedResult, + dnd: { + tracked: dnd.tracked, + history: Notifs.history.length, + popups: Notifs.popups.length, + unread: Notifs.unreadCount + }, + privacy: { + visible: Notifs.shouldShowOnLockScreen(privateNotification), + content: Notifs.shouldShowContentOnLockScreen(privateNotification) + } + }); + } + + function persist(): string { + root.reset(); + const notification = root.notification(5, "org.persist.App.desktop", "Persist"); + Notifs.handleNotification(notification); + Notifs.setAppRule("org.persist.App.desktop", { + enabled: false, + showOnLockScreen: true, + showContentOnLockScreen: false + }); + return JSON.stringify(DesktopPreferences.get("notificationAppRules")); + } + + function restored(): string { + return JSON.stringify(DesktopPreferences.get("notificationAppRules")); + } + } +} diff --git a/tests/quickshell/notification-app-rules-contract.sh b/tests/quickshell/notification-app-rules-contract.sh index 93ef26d..440aced 100755 --- a/tests/quickshell/notification-app-rules-contract.sh +++ b/tests/quickshell/notification-app-rules-contract.sh @@ -5,6 +5,7 @@ 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" +harness_fixture="$repo_dir/tests/quickshell/NotificationAppRulesHarness.qml" fail() { printf 'notification application rules contract: %s\n' "$1" >&2 @@ -13,6 +14,7 @@ fail() { [[ -f "$service" ]] || fail 'notification service is missing' [[ -f "$page" ]] || fail 'notification settings page is missing' +[[ -f "$harness_fixture" ]] || fail 'runtime harness fixture is missing' SERVICE_PATH="$service" PAGE_PATH="$page" bun -e ' const source = await Bun.file(process.env.SERVICE_PATH).text(); @@ -59,15 +61,19 @@ const explicitRule = normalizedAppRule({ enabled: false, showOnLockScreen: 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"]) { +for (const required of ["rememberApplication", "appRule", "setAppRule", "handleNotification", "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) +const handler = source.indexOf("function handleNotification(notification: var)"); +const tracked = source.indexOf("notification.tracked = true", handler); +const muted = source.indexOf("!root.appRule(appId).enabled", handler); +if (handler === -1 || tracked === -1 || muted === -1 || muted > tracked) fail("muted applications are not rejected before tracking/history/unread/toast work"); +if (!source.includes("root.handleNotification(notification)")) + fail("NotificationServer does not delegate delivery to the callable handler"); +if (!source.includes("PreferenceSchema.has(\"notificationAppRules\")")) + fail("the schema dependency is not explicit"); if (!source.includes("DesktopPreferences.get(\"notificationAppRules\")")) fail("rules are not read through DesktopPreferences"); if (!source.includes("DesktopPreferences.set(\"notificationAppRules\", next)")) @@ -78,6 +84,14 @@ 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 [ + "DesktopEntries.applications.values", + "DesktopEntries.byId(appId)", + "DesktopEntries.heuristicLookup(appId)" +]) { + if (!source.includes(required)) + fail(`persisted desktop entry ids are not reactively resolved through ${required}`); +} for (const required of [ "Notifs.applications", @@ -92,3 +106,98 @@ for (const required of [ console.log("notification application rules contract: PASS"); ' + +state_home="$(mktemp -d /tmp/panama-notification-rules-state.XXXXXX)" +config_home="$(mktemp -d /tmp/panama-notification-rules-config.XXXXXX)" +config_path="$state_home/quickshell" +harness="$config_path/notification-app-rules-harness.qml" +shell_log="$state_home/notification-app-rules.log" + +cleanup() { + if [[ -n "${bus_pid:-}" ]]; then + kill "$bus_pid" >/dev/null 2>&1 || true + fi + rm -rf "$state_home" "$config_home" +} +trap cleanup EXIT + +cp -a "$repo_dir/config/dot/quickshell" "$config_path" +cp "$harness_fixture" "$harness" + +# This is intentionally a copy-local integration dependency. The production +# schema is Claude's change; the runtime contract proves persistence only once +# that key exists and never stages a schema edit from this branch. +perl -0pi -e 's@(\n // ── Capture)@\n {\n key: "notificationAppRules", type: "json", def: {}, group: "notifications", internal: true\n },$1@' \ + "$config_path/config/PreferenceSchema.qml" +rg -q 'key: "notificationAppRules", type: "json"' "$config_path/config/PreferenceSchema.qml" \ + || fail 'temporary schema integration key was not installed' + +mapfile -t dbus_info < <(dbus-daemon --session --fork --print-address=1 --print-pid=1) +bus_address="${dbus_info[0]:-}" +bus_pid="${dbus_info[1]:-}" +[[ -n "$bus_address" && "$bus_pid" =~ ^[0-9]+$ ]] || fail 'private D-Bus session did not start' + +qs_for_test() { + DBUS_SESSION_BUS_ADDRESS="$bus_address" \ + XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \ + qs -p "$harness" "$@" +} + +stop_harness() { + qs_for_test kill >/dev/null 2>&1 || true + for _ in $(seq 1 40); do + ! qs_for_test ipc show >/dev/null 2>&1 && return + sleep 0.1 + done + fail 'isolated notification harness did not stop cleanly' +} + +start_harness() { + qs_for_test --daemonize >"$shell_log" 2>&1 + for _ in $(seq 1 40); do + qs_for_test ipc show 2>/dev/null | rg -q '^target notification-app-rules-test$' && return + sleep 0.1 + done + sed -n '1,240p' "$shell_log" >&2 + fail 'isolated notification harness did not start' +} + +start_harness +exercise="$(qs_for_test ipc call notification-app-rules-test exercise)" +jq -e ' + .appId == "org.signal.Signal.desktop" and + .initialRules == { + "org.signal.Signal.desktop": { + enabled: true, + showOnLockScreen: true, + showContentOnLockScreen: true + } + } and + .muted == { tracked: false, history: 0, popups: 0, unread: 0 } and + .dnd == { tracked: true, history: 1, popups: 0, unread: 1 } and + .privacy == { visible: false, content: false } +' <<<"$exercise" >/dev/null || fail "runtime notification policy fixture failed: $exercise" + +persisted="$(qs_for_test ipc call notification-app-rules-test persist)" +jq -e '. == { + "org.persist.App.desktop": { + enabled: false, + showOnLockScreen: true, + showContentOnLockScreen: false + } +}' <<<"$persisted" >/dev/null || fail "runtime persistence fixture wrote the wrong shape: $persisted" + +settings_file="$config_home/panama/settings.json" +for _ in $(seq 1 40); do + [[ -f "$settings_file" ]] && jq -e '.notificationAppRules["org.persist.App.desktop"].enabled == false' "$settings_file" >/dev/null && break + sleep 0.1 +done +[[ -f "$settings_file" ]] || fail 'runtime persistence fixture did not write settings.json' + +stop_harness +start_harness +restored="$(qs_for_test ipc call notification-app-rules-test restored)" +[[ "$restored" == "$persisted" ]] || fail "notification rules did not survive isolated restart: $restored" +stop_harness + +printf 'notification application rules runtime contract: PASS\n' From f22e405b50495b62c4a32de33ff66f221dcb8dd6 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 05:57:10 -0400 Subject: [PATCH 3/3] fix: resolve persisted notification app labels --- config/dot/quickshell/services/Notifs.qml | 6 +++- .../NotificationAppRulesHarness.qml | 13 +++++++ .../fixtures/org.persist.App.desktop | 5 +++ .../notification-app-rules-contract.sh | 35 +++++++++++++++++-- 4 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 tests/quickshell/fixtures/org.persist.App.desktop diff --git a/config/dot/quickshell/services/Notifs.qml b/config/dot/quickshell/services/Notifs.qml index a4cf51f..368ef77 100644 --- a/config/dot/quickshell/services/Notifs.qml +++ b/config/dot/quickshell/services/Notifs.qml @@ -88,7 +88,11 @@ Singleton { } function applicationLabel(appId: string, entries: var, remembered: var): string { - const entry = DesktopEntries.byId(appId) || DesktopEntries.heuristicLookup(appId); + 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; } diff --git a/tests/quickshell/NotificationAppRulesHarness.qml b/tests/quickshell/NotificationAppRulesHarness.qml index f4bdf8f..35c87f2 100644 --- a/tests/quickshell/NotificationAppRulesHarness.qml +++ b/tests/quickshell/NotificationAppRulesHarness.qml @@ -55,6 +55,11 @@ ShellRoot { const appId = Notifs.notificationAppId(signal); const initialRules = DesktopPreferences.get("notificationAppRules"); + const fallback = root.notification(6, "", "Fallback Terminal"); + Notifs.handleNotification(fallback); + const fallbackId = Notifs.notificationAppId(fallback); + const fallbackApplication = Notifs.applications.find(app => app.id === fallbackId); + root.resetNotifications(); Notifs.setAppRule(appId, { enabled: false }); const muted = root.notification(2, "org.signal.Signal.desktop", "Signal"); @@ -80,6 +85,10 @@ ShellRoot { return JSON.stringify({ appId: appId, initialRules: initialRules, + fallback: { + id: fallbackId, + application: fallbackApplication + }, muted: mutedResult, dnd: { tracked: dnd.tracked, @@ -109,5 +118,9 @@ ShellRoot { function restored(): string { return JSON.stringify(DesktopPreferences.get("notificationAppRules")); } + + function applications(): string { + return JSON.stringify(Notifs.applications); + } } } diff --git a/tests/quickshell/fixtures/org.persist.App.desktop b/tests/quickshell/fixtures/org.persist.App.desktop new file mode 100644 index 0000000..4f986f2 --- /dev/null +++ b/tests/quickshell/fixtures/org.persist.App.desktop @@ -0,0 +1,5 @@ +[Desktop Entry] +Type=Application +Name=Persisted Fixture App +Exec=/usr/bin/true +Icon=applications-system diff --git a/tests/quickshell/notification-app-rules-contract.sh b/tests/quickshell/notification-app-rules-contract.sh index 440aced..5568454 100755 --- a/tests/quickshell/notification-app-rules-contract.sh +++ b/tests/quickshell/notification-app-rules-contract.sh @@ -6,6 +6,7 @@ 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" harness_fixture="$repo_dir/tests/quickshell/NotificationAppRulesHarness.qml" +desktop_entry_fixture="$repo_dir/tests/quickshell/fixtures/org.persist.App.desktop" fail() { printf 'notification application rules contract: %s\n' "$1" >&2 @@ -15,6 +16,7 @@ fail() { [[ -f "$service" ]] || fail 'notification service is missing' [[ -f "$page" ]] || fail 'notification settings page is missing' [[ -f "$harness_fixture" ]] || fail 'runtime harness fixture is missing' +[[ -f "$desktop_entry_fixture" ]] || fail 'runtime desktop entry fixture is missing' SERVICE_PATH="$service" PAGE_PATH="$page" bun -e ' const source = await Bun.file(process.env.SERVICE_PATH).text(); @@ -109,6 +111,7 @@ console.log("notification application rules contract: PASS"); state_home="$(mktemp -d /tmp/panama-notification-rules-state.XXXXXX)" config_home="$(mktemp -d /tmp/panama-notification-rules-config.XXXXXX)" +data_home="$(mktemp -d /tmp/panama-notification-rules-data.XXXXXX)" config_path="$state_home/quickshell" harness="$config_path/notification-app-rules-harness.qml" shell_log="$state_home/notification-app-rules.log" @@ -117,12 +120,14 @@ cleanup() { if [[ -n "${bus_pid:-}" ]]; then kill "$bus_pid" >/dev/null 2>&1 || true fi - rm -rf "$state_home" "$config_home" + rm -rf "$state_home" "$config_home" "$data_home" } trap cleanup EXIT cp -a "$repo_dir/config/dot/quickshell" "$config_path" cp "$harness_fixture" "$harness" +mkdir -p "$data_home/applications" +cp "$desktop_entry_fixture" "$data_home/applications/org.persist.App.desktop" # This is intentionally a copy-local integration dependency. The production # schema is Claude's change; the runtime contract proves persistence only once @@ -140,6 +145,7 @@ bus_pid="${dbus_info[1]:-}" qs_for_test() { DBUS_SESSION_BUS_ADDRESS="$bus_address" \ XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \ + XDG_DATA_HOME="$data_home" XDG_DATA_DIRS="$data_home" \ qs -p "$harness" "$@" } @@ -175,7 +181,11 @@ jq -e ' } and .muted == { tracked: false, history: 0, popups: 0, unread: 0 } and .dnd == { tracked: true, history: 1, popups: 0, unread: 1 } and - .privacy == { visible: false, content: false } + .privacy == { visible: false, content: false } and + .fallback == { + id: "Fallback Terminal", + application: { id: "Fallback Terminal", name: "Fallback Terminal" } + } ' <<<"$exercise" >/dev/null || fail "runtime notification policy fixture failed: $exercise" persisted="$(qs_for_test ipc call notification-app-rules-test persist)" @@ -194,10 +204,31 @@ for _ in $(seq 1 40); do done [[ -f "$settings_file" ]] || fail 'runtime persistence fixture did not write settings.json' +wait_for_persisted_application() { + local expected="$1" + local applications="" + for _ in $(seq 1 80); do + applications="$(qs_for_test ipc call notification-app-rules-test applications)" + if jq -e '.[] | select(.id == "org.persist.App.desktop" and .name == "Persisted Fixture App")' \ + <<<"$applications" >/dev/null; then + [[ "$applications" == *"$expected"* ]] && printf '%s' "$applications" && return + fi + sleep 0.1 + done + fail "persisted desktop entry did not resolve to a friendly name: $applications" +} + +before_restart_applications="$(wait_for_persisted_application 'Persisted Fixture App')" + stop_harness start_harness restored="$(qs_for_test ipc call notification-app-rules-test restored)" [[ "$restored" == "$persisted" ]] || fail "notification rules did not survive isolated restart: $restored" +after_restart_applications="$(wait_for_persisted_application 'Persisted Fixture App')" +[[ "$before_restart_applications" == *'"id":"org.persist.App.desktop","name":"Persisted Fixture App"'* ]] \ + || fail "persisted application name was wrong before restart: $before_restart_applications" +[[ "$after_restart_applications" == *'"id":"org.persist.App.desktop","name":"Persisted Fixture App"'* ]] \ + || fail "persisted application name was wrong after restart: $after_restart_applications" stop_harness printf 'notification application rules runtime contract: PASS\n'