feat: add notification application rules
This commit is contained in:
@@ -50,6 +50,70 @@ SettingsPage {
|
|||||||
SliderRow { setting: "maxVisibleToasts"; divider: false }
|
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 {
|
SettingsCard {
|
||||||
title: "Focus sessions"
|
title: "Focus sessions"
|
||||||
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
|
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
|
||||||
|
|||||||
@@ -37,6 +37,31 @@ Singleton {
|
|||||||
// Cleared when the notification centre is opened. The bar binds to this.
|
// Cleared when the notification centre is opened. The bar binds to this.
|
||||||
property int unreadCount: 0
|
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
|
// Arrival times, keyed by notification id — the protocol carries no
|
||||||
// timestamp. Deliberately formatted once at arrival rather than shown as
|
// timestamp. Deliberately formatted once at arrival rather than shown as
|
||||||
// "5 minutes ago", which would need a clock ticking behind every card.
|
// "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
|
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
|
// history grouped by app, in most-recent-app-first order — the shape
|
||||||
// NotificationCenter.qml renders directly.
|
// NotificationCenter.qml renders directly.
|
||||||
readonly property var groups: {
|
readonly property var groups: {
|
||||||
@@ -92,6 +181,10 @@ Singleton {
|
|||||||
if (notification.lastGeneration)
|
if (notification.lastGeneration)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
const appId = root.rememberApplication(notification);
|
||||||
|
if (!root.appRule(appId).enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
// Without this the object is destroyed the instant this returns.
|
// Without this the object is destroyed the instant this returns.
|
||||||
notification.tracked = true;
|
notification.tracked = true;
|
||||||
root.arrivals[notification.id] = new Date();
|
root.arrivals[notification.id] = new Date();
|
||||||
|
|||||||
+94
@@ -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");
|
||||||
|
'
|
||||||
Reference in New Issue
Block a user