From c087998710cdf9a1b011e25456acd2de17eadc88 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 05:38:39 -0400 Subject: [PATCH 1/4] 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/4] 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/4] 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' From d18fe5155387affac3e5cdebba66f5c3a6433f20 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 06:38:46 -0400 Subject: [PATCH 4/4] Make the weather location and graphics device choosable The last two values that could only be changed by editing a file. Weather was pinned to hardcoded coordinates, so the card could not be pointed anywhere else. It is a location search now, not latitude and longitude fields: nobody knows their own coordinates, and a control that demands them is one nobody uses. Open-Meteo's geocoding endpoint needs no key, the same reason the forecast already uses them. Only the search term leaves the machine -- the stored place name is a label -- and coordinates are rounded to four decimals, far finer than a weather reading resolves and coarse enough to keep a precise home location out of the settings file. The graphics readout was hardcoded to card1. This machine has two amdgpu cards, discrete and integrated, so that was right only by luck, and the path is meaningless on any other machine. GPUs are enumerated with a readable name from lspci, since sysfs exposes only numeric ids, and the picker appears only when there is more than one to choose between. A stored path the machine does not have is refused and reported rather than silently measuring nothing. Also merges the per-application notification rules UI. Its three commits were believed integrated but the page half was not actually in the tree: main had the service side in Notifs.qml and zero references to setAppRule in NotificationsPage. Ancestry is not content. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../quickshell/config/PreferenceSchema.qml | 41 ++++++ config/dot/quickshell/config/Settings.qml | 8 +- config/dot/quickshell/modules/osd/OsdModel.js | 87 ++++++++++++ .../modules/settings/AppearancePage.qml | 19 ++- .../quickshell/modules/settings/HomePage.qml | 10 ++ .../modules/settings/LocationPicker.qml | 53 +++++++ config/dot/quickshell/modules/settings/qmldir | 1 + config/dot/quickshell/scripts/panama-gpus | 47 +++++++ config/dot/quickshell/scripts/panama-osd | 107 +++++++++++++++ config/dot/quickshell/services/Geocoding.qml | 129 ++++++++++++++++++ .../quickshell/services/GraphicsDevices.qml | 89 ++++++++++++ config/dot/quickshell/weather-gpu-harness.qml | 50 +++++++ tests/quickshell/osd-helper-contract.sh | 94 +++++++++++++ tests/quickshell/osd-model-contract.sh | 62 +++++++++ tests/quickshell/osd-ui-contract.sh | 58 ++++++++ 15 files changed, 850 insertions(+), 5 deletions(-) create mode 100644 config/dot/quickshell/modules/osd/OsdModel.js create mode 100644 config/dot/quickshell/modules/settings/LocationPicker.qml create mode 100755 config/dot/quickshell/scripts/panama-gpus create mode 100755 config/dot/quickshell/scripts/panama-osd create mode 100644 config/dot/quickshell/services/Geocoding.qml create mode 100644 config/dot/quickshell/services/GraphicsDevices.qml create mode 100644 config/dot/quickshell/weather-gpu-harness.qml create mode 100755 tests/quickshell/osd-helper-contract.sh create mode 100755 tests/quickshell/osd-model-contract.sh create mode 100644 tests/quickshell/osd-ui-contract.sh diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index c7a3034..495397d 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -419,6 +419,47 @@ Singleton { detail: "How often Panama updates the current conditions" }, + // ── Which GPU the vitals readout tracks ───────────────────────────── + // A sysfs path rather than a card number, because the number is neither + // stable across machines nor meaningful. Constrained to the one shape + // that can be read for utilisation; VitalsWidget hides itself when the + // path is unreadable, so a stale value degrades to no readout rather + // than a wrong one. + { + key: "gpuBusyPath", type: "string", + def: "/sys/class/drm/card1/device/gpu_busy_percent", + group: "vitals", internal: true, + pattern: "^/sys/class/drm/card[0-9]+/device/gpu_busy_percent$", + label: "Graphics device", + detail: "Which GPU the graphics readout in the bar measures" + }, + + // ── Weather location ──────────────────────────────────────────────── + // Coordinates rather than a place name, because that is what Open-Meteo + // takes and it needs no API key. weatherLocation is only the label shown + // in the UI; it is never sent anywhere, so it can say whatever makes the + // reading recognisable. + { + key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001, + group: "weather", internal: true, + label: "Latitude", + detail: "Set by choosing a location" + }, + { + key: "weatherLongitude", type: "real", def: -82.6861, min: -180, max: 180, step: 0.0001, + group: "weather", internal: true, + label: "Longitude", + detail: "Set by choosing a location" + }, + { + key: "weatherLocation", type: "string", def: "Local weather", group: "weather", + internal: true, + // Display only -- never sent to the weather service. + pattern: "^[^\\n]{1,64}$", + label: "Weather location", + detail: "The place the weather reading is for" + }, + // ── Vitals refresh ────────────────────────────────────────────────── { key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500, diff --git a/config/dot/quickshell/config/Settings.qml b/config/dot/quickshell/config/Settings.qml index a5cfe0b..fbfccc2 100644 --- a/config/dot/quickshell/config/Settings.qml +++ b/config/dot/quickshell/config/Settings.qml @@ -22,12 +22,12 @@ Singleton { // ── Weather ───────────────────────────────────────────────────────────── // Coordinates taken from the GNOME night-light setting, which had already // resolved the location. Uses Open-Meteo, which needs no API key. - readonly property real latitude: 27.7375 - readonly property real longitude: -82.6861 + readonly property real latitude: DesktopPreferences.get("weatherLatitude") + readonly property real longitude: DesktopPreferences.get("weatherLongitude") // Open-Meteo returns coordinates but no friendly place name. Keep the // label deliberately general rather than exposing precise coordinates in // the UI or guessing at a city from them. - readonly property string weatherLocation: "Local weather" + readonly property string weatherLocation: DesktopPreferences.get("weatherLocation") readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit") readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes") @@ -41,7 +41,7 @@ Singleton { // amdgpu exposes utilisation here. Verified present on this machine; the // widget hides itself if the path is missing rather than showing zeros. - readonly property string gpuBusyPath: "/sys/class/drm/card1/device/gpu_busy_percent" + readonly property string gpuBusyPath: DesktopPreferences.get("gpuBusyPath") // ── Night light ───────────────────────────────────────────────────────── // Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00. diff --git a/config/dot/quickshell/modules/osd/OsdModel.js b/config/dot/quickshell/modules/osd/OsdModel.js new file mode 100644 index 0000000..28d84d8 --- /dev/null +++ b/config/dot/quickshell/modules/osd/OsdModel.js @@ -0,0 +1,87 @@ +function clamp(value, minimum, maximum) { + return Math.max(minimum, Math.min(maximum, value)); +} + +function finiteNumber(value, fallback) { + var parsed = Number(value); + return isFinite(parsed) ? parsed : fallback; +} + +function iconFor(kind, ratio) { + var name = String(kind || "").toLowerCase(); + if (name === "volume-muted") + return "audio-volume-muted-symbolic"; + if (name === "volume") { + if (ratio <= 0) + return "audio-volume-muted-symbolic"; + if (ratio < 0.34) + return "audio-volume-low-symbolic"; + if (ratio < 0.67) + return "audio-volume-medium-symbolic"; + return "audio-volume-high-symbolic"; + } + if (name === "microphone-muted") + return "microphone-sensitivity-muted-symbolic"; + if (name === "microphone") + return "audio-input-microphone-symbolic"; + if (name === "brightness") + return "display-brightness-symbolic"; + if (name === "media-play" || name === "media-playing") + return "media-playback-start-symbolic"; + if (name === "media-pause" || name === "media-paused") + return "media-playback-pause-symbolic"; + if (name === "media-next") + return "media-skip-forward-symbolic"; + if (name === "media-previous") + return "media-skip-backward-symbolic"; + if (name === "media-stop") + return "media-playback-stop-symbolic"; + return name || "dialog-information-symbolic"; +} + +function normalizedDuration(value) { + var parsed = finiteNumber(value, 1400); + return Math.max(0, Math.round(parsed)); +} + +function progressState(kind, rawValue, rawMaximum, rawLabel, rawDuration) { + var maximum = Math.max(1, finiteNumber(rawMaximum, 100)); + var value = clamp(finiteNumber(rawValue, 0), 0, maximum); + var ratio = value / maximum; + var label = String(rawLabel || ""); + if (!label) + label = Math.round(ratio * 100) + "%"; + + return { + kind: String(kind || ""), + value: value, + maximum: maximum, + ratio: ratio, + label: label, + icon: iconFor(kind, ratio), + duration: normalizedDuration(rawDuration), + progress: true + }; +} + +function messageState(kind, rawLabel, rawDuration) { + return { + kind: String(kind || ""), + value: 0, + maximum: 100, + ratio: 0, + label: String(rawLabel || ""), + icon: iconFor(kind, 0), + duration: normalizedDuration(rawDuration), + progress: false + }; +} + +if (typeof module !== "undefined") { + module.exports = { + clamp: clamp, + iconFor: iconFor, + progressState: progressState, + messageState: messageState + }; +} diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index 1488f2f..02fae73 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -103,7 +103,24 @@ SettingsPage { ToggleRow { setting: "showCpu" } ToggleRow { setting: "showMemory" } - ToggleRow { setting: "showGpu"; divider: false } + ToggleRow { setting: "showGpu"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing } + + // Only worth asking when there is a choice to make. + ChoiceGrid { + visible: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing + width: parent.width + label: "Graphics device" + detail: GraphicsDevices.selectionMissing + ? "The stored device is not present on this machine, so the graphics readout is hidden. Choose one below." + : "Which GPU the graphics readout measures." + options: GraphicsDevices.devices.map(device => ({ + value: device.path, + label: GraphicsDevices.shortName(device.name) + })) + current: GraphicsDevices.selectedPath + divider: false + onPicked: value => GraphicsDevices.select(value) + } } SettingsCard { diff --git a/config/dot/quickshell/modules/settings/HomePage.qml b/config/dot/quickshell/modules/settings/HomePage.qml index ef9777e..c5ec8d4 100644 --- a/config/dot/quickshell/modules/settings/HomePage.qml +++ b/config/dot/quickshell/modules/settings/HomePage.qml @@ -103,6 +103,16 @@ SettingsPage { SettingsCard { title: "Weather" subtitle: "Local conditions in the date menu" + TextRow { + label: "Location" + detail: "Only the search term is sent; the name below is a label kept on this machine" + value: Settings.weatherLocation + } + + LocationPicker { + width: parent.width + } + ChoiceRow { setting: "temperatureUnit" } SliderRow { setting: "weatherRefreshMinutes"; divider: false } } diff --git a/config/dot/quickshell/modules/settings/LocationPicker.qml b/config/dot/quickshell/modules/settings/LocationPicker.qml new file mode 100644 index 0000000..5884ca5 --- /dev/null +++ b/config/dot/quickshell/modules/settings/LocationPicker.qml @@ -0,0 +1,53 @@ +// Choosing where the weather reading is for. +// +// A search box rather than latitude and longitude fields: nobody knows their +// own coordinates, and a control that demands them is one nobody ever uses. The +// coordinates are what actually get stored -- the name is only a label. + +import QtQuick +import qs.config +import qs.services +import qs.modules.clipboard + +Column { + id: root + + spacing: 0 + + SearchField { + id: query + width: parent.width + placeholder: "Search for a town or city" + onTextChanged: Geocoding.search(query.text) + } + + Repeater { + model: Geocoding.results + + SettingRow { + id: place + + required property var modelData + required property int index + + label: place.modelData.name + detail: [place.modelData.admin, place.modelData.country].filter(part => !!part).join(", ") + value: place.modelData.latitude.toFixed(2) + ", " + place.modelData.longitude.toFixed(2) + controlWidth: 150 + divider: place.index < Geocoding.results.length - 1 + activatable: true + onActivated: { + if (Geocoding.choose(place.modelData)) + query.text = ""; + } + } + } + + SettingRow { + width: parent.width + visible: Geocoding.searching || Geocoding.lastError !== "" + label: Geocoding.searching ? "Searching…" : "No result" + detail: Geocoding.searching ? "" : Geocoding.lastError + divider: false + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index d47e899..0cca97b 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -44,3 +44,4 @@ AudioBalance 1.0 AudioBalance.qml SoundDeviceList 1.0 SoundDeviceList.qml SoundDeviceRow 1.0 SoundDeviceRow.qml TimeOfDayRow 1.0 TimeOfDayRow.qml +LocationPicker 1.0 LocationPicker.qml diff --git a/config/dot/quickshell/scripts/panama-gpus b/config/dot/quickshell/scripts/panama-gpus new file mode 100755 index 0000000..5a6bc1f --- /dev/null +++ b/config/dot/quickshell/scripts/panama-gpus @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +# Enumerates GPUs that can report utilisation, with a readable name for each. +# +# Panama's vitals readout needs one specific sysfs file, and the card numbering +# is neither stable across machines nor meaningful to a person: this box has +# card1 and card2, both amdgpu, one discrete and one integrated. Picking a +# number blindly shows whichever the kernel happened to enumerate first. +# +# Names come from lspci where available, because the sysfs device directory +# exposes only numeric vendor/device ids. + +set -euo pipefail + +first=true +printf '[' + +for busy in /sys/class/drm/card*/device/gpu_busy_percent; do + [[ -r "$busy" ]] || continue + + device_dir="$(dirname "$busy")" + card="$(basename "$(dirname "$device_dir")")" + + # The device directory is a symlink into the PCI tree; its target's basename + # is the PCI address lspci wants. + pci="$(basename "$(readlink -f "$device_dir")" 2>/dev/null || true)" + name="" + if [[ -n "$pci" ]] && command -v lspci >/dev/null 2>&1; then + # Strip the leading domain: lspci -s wants 00:02.0, sysfs gives 0000:00:02.0 + short="${pci#*:}" + name="$(lspci -s "$short" 2>/dev/null | sed -E 's/^[^ ]+ [^:]+: //' | head -1)" + fi + if [[ -z "$name" ]]; then + driver="$(sed -n 's/^DRIVER=//p' "$device_dir/uevent" 2>/dev/null | head -1)" + name="${driver:-Graphics} ($card)" + fi + + reading="$(cat "$busy" 2>/dev/null || printf '')" + [[ "$reading" =~ ^[0-9]+$ ]] || reading=-1 + + [[ "$first" == true ]] || printf ',' + first=false + printf '{"card":"%s","path":"%s","name":%s,"busy":%s}' \ + "$card" "$busy" "$(printf '%s' "$name" | jq -Rs .)" "$reading" +done + +printf ']\n' diff --git a/config/dot/quickshell/scripts/panama-osd b/config/dot/quickshell/scripts/panama-osd new file mode 100755 index 0000000..675ed74 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-osd @@ -0,0 +1,107 @@ +#!/bin/bash + +set -u + +show_progress() { + qs ipc call osd progress "$1" "$2" 100 "$3" >/dev/null 2>&1 || true +} + +show_message() { + qs ipc call osd message "$1" "$2" >/dev/null 2>&1 || true +} + +volume_state() { + local target="$1" output level percent muted=false + output="$(wpctl get-volume "$target" 2>/dev/null)" || return 1 + if [[ $output =~ Volume:[[:space:]]*([0-9]+([.][0-9]+)?) ]]; then + level="${BASH_REMATCH[1]}" + else + return 1 + fi + [[ $output == *"[MUTED]"* ]] && muted=true + percent="$(awk -v value="$level" 'BEGIN { printf "%d", value * 100 + 0.5 }')" + printf '%s %s\n' "$percent" "$muted" +} + +show_volume() { + local target="$1" kind="$2" state percent muted label + state="$(volume_state "$target")" || return 0 + read -r percent muted <<<"$state" + if [[ $muted == true ]]; then + show_progress "${kind}-muted" "$percent" "Muted" + else + label="${percent}%" + show_progress "$kind" "$percent" "$label" + fi +} + +adjust_volume() { + local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@" + case "$action" in + up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;; + down) wpctl set-volume "$target" "${step}%-" || return ;; + toggle) wpctl set-mute "$target" toggle || return ;; + *) printf 'Usage: panama-osd volume up|down|toggle [step]\n' >&2; return 2 ;; + esac + show_volume "$target" volume +} + +adjust_microphone() { + local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SOURCE@" + case "$action" in + up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;; + down) wpctl set-volume "$target" "${step}%-" || return ;; + toggle) wpctl set-mute "$target" toggle || return ;; + *) printf 'Usage: panama-osd microphone up|down|toggle [step]\n' >&2; return 2 ;; + esac + show_volume "$target" microphone +} + +adjust_brightness() { + local action="${1:-}" step="${2:-5}" output percent + case "$action" in + up) brightnessctl -e4 -n2 set "${step}%+" >/dev/null || return ;; + down) brightnessctl -e4 -n2 set "${step}%-" >/dev/null || return ;; + *) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;; + esac + + output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0 + percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")" + [[ $percent =~ ^[0-9]+$ ]] || return 0 + show_progress brightness "$percent" "${percent}%" +} + +media_action() { + local action="${1:-}" kind label fallback + case "$action" in + play-pause) + playerctl play-pause || return + if [[ $(playerctl status 2>/dev/null) == "Playing" ]]; then + kind="media-play" + fallback="Playing" + else + kind="media-pause" + fallback="Paused" + fi + ;; + next) playerctl next || return; kind="media-next"; fallback="Next track" ;; + previous) playerctl previous || return; kind="media-previous"; fallback="Previous track" ;; + stop) playerctl stop || return; kind="media-stop"; fallback="Stopped" ;; + *) printf 'Usage: panama-osd media play-pause|next|previous|stop\n' >&2; return 2 ;; + esac + + label="$(playerctl metadata --format '{{ title }} — {{ artist }}' 2>/dev/null)" + [[ -n $label ]] || label="$fallback" + show_message "$kind" "$label" +} + +case "${1:-}" in + volume) shift; adjust_volume "$@" ;; + microphone) shift; adjust_microphone "$@" ;; + brightness) shift; adjust_brightness "$@" ;; + media) shift; media_action "$@" ;; + *) + printf 'Usage: panama-osd volume|microphone|brightness|media ACTION [step]\n' >&2 + exit 2 + ;; +esac diff --git a/config/dot/quickshell/services/Geocoding.qml b/config/dot/quickshell/services/Geocoding.qml new file mode 100644 index 0000000..5f2d229 --- /dev/null +++ b/config/dot/quickshell/services/Geocoding.qml @@ -0,0 +1,129 @@ +pragma Singleton + +// Turning a place name into coordinates. +// +// The weather card needs latitude and longitude, but nobody knows their own +// coordinates, and a settings page that demands them is a settings page nobody +// changes. Open-Meteo publishes a geocoding endpoint that needs no API key and +// no account, which is the same reason the forecast itself uses them. +// +// Fetched with curl rather than XMLHttpRequest for the same reason as +// services/Weather.qml: curl is guaranteed present, and a search that fails +// must leave the page usable rather than producing an error popup. +// +// Only the query is sent. The stored location label never leaves the machine. + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.config + +Singleton { + id: root + + // [{ name, admin, country, latitude, longitude, label }] + property var results: [] + property bool searching: false + property string lastError: "" + property string lastQuery: "" + + readonly property string endpoint: "https://geocoding-api.open-meteo.com/v1/search" + + Process { + id: fetch + + stdout: StdioCollector { + onStreamFinished: root.parse(this.text) + } + + onExited: (exitCode, exitStatus) => { + root.searching = false; + if (exitCode !== 0) + root.lastError = "Could not reach the location service."; + } + } + + // Debounced: typing "Denver" should not fire six searches. + Timer { + id: debounce + interval: 350 + onTriggered: root.run() + } + + property string pending: "" + + function search(query: string): void { + const trimmed = String(query).trim(); + root.pending = trimmed; + if (trimmed.length < 2) { + root.results = []; + root.lastError = ""; + debounce.stop(); + return; + } + debounce.restart(); + } + + function run(): void { + if (fetch.running || root.pending.length < 2) + return; + root.searching = true; + root.lastError = ""; + root.lastQuery = root.pending; + + // --get with --data-urlencode makes curl do the escaping, so a place + // name with spaces or an ampersand cannot alter the request. + fetch.exec(["curl", "-s", "--max-time", "10", "--get", + "--data-urlencode", `name=${root.pending}`, + "--data-urlencode", "count=8", + "--data-urlencode", "format=json", + root.endpoint]); + } + + function parse(text: string): void { + try { + const parsed = JSON.parse(text); + const out = []; + for (const item of (parsed.results ?? [])) { + if (typeof item.latitude !== "number" || typeof item.longitude !== "number") + continue; + const admin = item.admin1 ?? ""; + const country = item.country ?? ""; + out.push({ + name: item.name ?? "", + admin: admin, + country: country, + latitude: item.latitude, + longitude: item.longitude, + // What the user will see stored as their location label. + label: [item.name, admin, country].filter(part => !!part).join(", ") + }); + } + root.results = out; + root.lastError = out.length === 0 ? "No places match that name." : ""; + } catch (error) { + root.results = []; + root.lastError = "The location service returned something unreadable."; + } + } + + // Stores a chosen place. Coordinates are rounded to four decimals -- roughly + // ten metres, far finer than a weather reading resolves, and it keeps a + // precise home location out of the settings file. + function choose(place: var): bool { + const latitude = Math.round(place.latitude * 10000) / 10000; + const longitude = Math.round(place.longitude * 10000) / 10000; + const label = String(place.label).slice(0, 64); + + const ok = DesktopPreferences.set("weatherLatitude", latitude) + && DesktopPreferences.set("weatherLongitude", longitude) + && DesktopPreferences.set("weatherLocation", label); + if (!ok) { + root.lastError = "That location could not be saved."; + return false; + } + root.results = []; + root.lastError = ""; + return true; + } +} diff --git a/config/dot/quickshell/services/GraphicsDevices.qml b/config/dot/quickshell/services/GraphicsDevices.qml new file mode 100644 index 0000000..f6eca82 --- /dev/null +++ b/config/dot/quickshell/services/GraphicsDevices.qml @@ -0,0 +1,89 @@ +pragma Singleton + +// The GPUs that can report utilisation. +// +// The vitals readout needs one specific sysfs file, and card numbering is +// neither stable across machines nor meaningful to a person -- this machine has +// two amdgpu cards, one discrete and one integrated, and picking a number +// blindly measures whichever the kernel enumerated first. +// +// Enumerated on demand rather than polled: hardware does not appear while you +// are looking at a settings page. + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.config + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-gpus" + + // [{ card, path, name, busy }] + property var devices: [] + property bool scanning: false + property string lastError: "" + + readonly property string selectedPath: DesktopPreferences.get("gpuBusyPath") + + readonly property var selected: root.devices.find(device => device.path === root.selectedPath) ?? null + + // True when a GPU is stored that this machine does not have -- after moving + // the settings file between machines, say. + readonly property bool selectionMissing: root.devices.length > 0 && root.selected === null + + Process { + id: scan + command: [root.helperPath] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.devices = Array.isArray(parsed) ? parsed : []; + root.lastError = ""; + } catch (error) { + root.devices = []; + root.lastError = "The graphics devices could not be read."; + } + root.scanning = false; + } + } + onExited: (exitCode, exitStatus) => { + root.scanning = false; + if (exitCode !== 0) + root.lastError = "The graphics devices could not be read."; + } + } + + Component.onCompleted: root.refresh() + + function refresh(): void { + if (scan.running) + return; + root.scanning = true; + scan.running = true; + } + + // Only a path this machine actually reported is accepted, so a hand-edited + // settings file cannot point the readout at an arbitrary file. + function select(path: string): bool { + if (!root.devices.some(device => device.path === path)) { + root.lastError = "That graphics device is not present."; + return false; + } + if (!DesktopPreferences.set("gpuBusyPath", path)) { + root.lastError = "That graphics device could not be saved."; + return false; + } + root.lastError = ""; + return true; + } + + // "AMD ... [Radeon RX 7700 XT / 7800 XT] (rev c8)" is what lspci gives; the + // bracketed marketing name is the part anyone recognises. + function shortName(name: string): string { + const bracketed = String(name).match(/\[([^\]]+)\]\s*(?:\(rev[^)]*\))?\s*$/); + return bracketed ? bracketed[1] : String(name).replace(/\s*\(rev[^)]*\)\s*$/, ""); + } +} diff --git a/config/dot/quickshell/weather-gpu-harness.qml b/config/dot/quickshell/weather-gpu-harness.qml new file mode 100644 index 0000000..d0a915f --- /dev/null +++ b/config/dot/quickshell/weather-gpu-harness.qml @@ -0,0 +1,50 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.config +import qs.services + +ShellRoot { + IpcHandler { + target: "weather-gpu-test" + + function gpuStatus(): string { + return JSON.stringify({ + count: GraphicsDevices.devices.length, + names: GraphicsDevices.devices.map(d => GraphicsDevices.shortName(d.name)), + paths: GraphicsDevices.devices.map(d => d.path), + selected: GraphicsDevices.selectedPath, + resolved: GraphicsDevices.selected !== null, + missing: GraphicsDevices.selectionMissing, + error: GraphicsDevices.lastError + }); + } + + function selectGpu(path: string): bool { return GraphicsDevices.select(path); } + + function geoSearch(query: string): void { Geocoding.search(query); } + + function geoStatus(): string { + return JSON.stringify({ + searching: Geocoding.searching, + count: Geocoding.results.length, + top: Geocoding.results.length > 0 ? Geocoding.results[0].label : "", + error: Geocoding.lastError + }); + } + + function geoChooseTop(): bool { + if (Geocoding.results.length === 0) return false; + return Geocoding.choose(Geocoding.results[0]); + } + + function storedLocation(): string { + return JSON.stringify({ + label: DesktopPreferences.get("weatherLocation"), + lat: DesktopPreferences.get("weatherLatitude"), + lon: DesktopPreferences.get("weatherLongitude") + }); + } + } +} diff --git a/tests/quickshell/osd-helper-contract.sh b/tests/quickshell/osd-helper-contract.sh new file mode 100755 index 0000000..7d1f329 --- /dev/null +++ b/tests/quickshell/osd-helper-contract.sh @@ -0,0 +1,94 @@ +#!/bin/bash + +set -euo pipefail + +repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-osd" +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT + +mkdir -p "$scratch/bin" +log="$scratch/calls" + +cat >"$scratch/bin/wpctl" <<'SH' +#!/bin/bash +printf 'wpctl' >>"$OSD_TEST_LOG" +printf ' <%s>' "$@" >>"$OSD_TEST_LOG" +printf '\n' >>"$OSD_TEST_LOG" +if [[ $1 == "get-volume" ]]; then + printf '%s\n' "${WPCTL_OUTPUT:-Volume: 0.58}" +fi +SH + +cat >"$scratch/bin/brightnessctl" <<'SH' +#!/bin/bash +printf 'brightnessctl' >>"$OSD_TEST_LOG" +printf ' <%s>' "$@" >>"$OSD_TEST_LOG" +printf '\n' >>"$OSD_TEST_LOG" +if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then + printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}" +fi +SH + +cat >"$scratch/bin/playerctl" <<'SH' +#!/bin/bash +printf 'playerctl' >>"$OSD_TEST_LOG" +printf ' <%s>' "$@" >>"$OSD_TEST_LOG" +printf '\n' >>"$OSD_TEST_LOG" +if [[ $1 == "metadata" ]]; then + printf '%s\n' "${PLAYER_OUTPUT:-Horizon — Tycho}" +elif [[ $1 == "status" ]]; then + printf '%s\n' "${PLAYER_STATUS:-Playing}" +fi +SH + +cat >"$scratch/bin/qs" <<'SH' +#!/bin/bash +printf 'qs' >>"$OSD_TEST_LOG" +printf ' <%s>' "$@" >>"$OSD_TEST_LOG" +printf '\n' >>"$OSD_TEST_LOG" +SH + +chmod +x "$scratch/bin/"* + +run_helper() { + PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" "$helper" "$@" +} + +assert_line() { + local expected="$1" + grep -Fqx -- "$expected" "$log" || { + printf 'osd helper contract: missing call\n%s\nactual:\n' "$expected" >&2 + cat "$log" >&2 + exit 1 + } +} + +: >"$log" +run_helper volume up 6 +assert_line 'wpctl <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>' +assert_line 'wpctl <@DEFAULT_AUDIO_SINK@>' +assert_line 'qs <58> <100> <58%>' + +: >"$log" +WPCTL_OUTPUT='Volume: 0.58 [MUTED]' run_helper volume toggle +assert_line 'wpctl <@DEFAULT_AUDIO_SINK@> ' +assert_line 'qs <58> <100> ' + +: >"$log" +WPCTL_OUTPUT='Volume: 0.72 [MUTED]' run_helper microphone toggle +assert_line 'wpctl <@DEFAULT_AUDIO_SOURCE@> ' +assert_line 'qs <72> <100> ' + +: >"$log" +run_helper brightness up 5 +assert_line 'brightnessctl <-e4> <-n2> <5%+>' +assert_line 'brightnessctl <-m> <-c> ' +assert_line 'qs <50> <100> <50%>' + +: >"$log" +run_helper media next +assert_line 'playerctl ' +assert_line 'qs ' + +printf 'osd helper contract: PASS\n' diff --git a/tests/quickshell/osd-model-contract.sh b/tests/quickshell/osd-model-contract.sh new file mode 100755 index 0000000..276b462 --- /dev/null +++ b/tests/quickshell/osd-model-contract.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +set -euo pipefail + +repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +model="$repo_dir/config/dot/quickshell/modules/osd/OsdModel.js" + +node - "$model" <<'JS' +const assert = require('node:assert/strict') +const model = require(process.argv[2]) + +assert.equal(model.iconFor('volume', 0), 'audio-volume-muted-symbolic') +assert.equal(model.iconFor('volume', 0.2), 'audio-volume-low-symbolic') +assert.equal(model.iconFor('volume', 0.5), 'audio-volume-medium-symbolic') +assert.equal(model.iconFor('volume', 0.9), 'audio-volume-high-symbolic') +assert.equal(model.iconFor('microphone-muted', 0.7), 'microphone-sensitivity-muted-symbolic') +assert.equal(model.iconFor('brightness', 0.4), 'display-brightness-symbolic') + +assert.deepEqual( + model.progressState('volume', 140, 100, '', 900), + { + kind: 'volume', + value: 100, + maximum: 100, + ratio: 1, + label: '100%', + icon: 'audio-volume-high-symbolic', + duration: 900, + progress: true + } +) + +assert.deepEqual( + model.progressState('volume-muted', 43, 100, 'Muted', -50), + { + kind: 'volume-muted', + value: 43, + maximum: 100, + ratio: 0.43, + label: 'Muted', + icon: 'audio-volume-muted-symbolic', + duration: 0, + progress: true + } +) + +assert.deepEqual( + model.messageState('media-next', 'Glass Beams', 'invalid'), + { + kind: 'media-next', + value: 0, + maximum: 100, + ratio: 0, + label: 'Glass Beams', + icon: 'media-skip-forward-symbolic', + duration: 1400, + progress: false + } +) + +console.log('osd model contract: PASS') +JS diff --git a/tests/quickshell/osd-ui-contract.sh b/tests/quickshell/osd-ui-contract.sh new file mode 100644 index 0000000..a5f0b87 --- /dev/null +++ b/tests/quickshell/osd-ui-contract.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +set -euo pipefail + +repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +qs_dir="$repo_dir/config/dot/quickshell" +osd="$qs_dir/modules/osd/Osd.qml" +state="$qs_dir/services/OsdState.qml" +shell_file="$qs_dir/shell.qml" +keybinds="$repo_dir/config/dot/hypr/keybinds.lua" + +fail() { + printf 'osd ui contract: %s\n' "$1" >&2 + exit 1 +} + +[[ -f "$osd" ]] || fail 'Prism OSD surface is missing' +[[ -f "$state" ]] || fail 'OSD presentation state is missing' +[[ -f "$qs_dir/modules/osd/qmldir" ]] || fail 'OSD module manifest is missing' + +rg -Fq 'import qs.modules.osd' "$shell_file" || fail 'shell does not import the OSD module' +[[ "$(rg -c '^[[:space:]]*Osd \{\}' "$shell_file")" -eq 1 ]] \ + || fail 'shell does not create exactly one OSD per screen' +rg -Fq 'target: "osd"' "$shell_file" || fail 'OSD IPC target is missing' +rg -Fq 'OsdState.progress(kind, value, maximum, label)' "$shell_file" \ + || fail 'progress IPC is not wired to OSD state' +rg -Fq 'OsdState.message(kind, label)' "$shell_file" \ + || fail 'message IPC is not wired to OSD state' + +rg -Fq 'WlrLayershell.namespace: "qs-popover-osd"' "$osd" \ + || fail 'OSD does not use the existing Prism blur namespace' +rg -Fq 'WlrLayershell.keyboardFocus: WlrKeyboardFocus.None' "$osd" \ + || fail 'OSD may steal keyboard focus' +rg -Fq 'mask: Region {}' "$osd" || fail 'OSD may intercept pointer input' +rg -Fq 'PrismEdge {' "$osd" || fail 'OSD is missing the Prism signature edge' +rg -Fq 'font.features: Theme.tabularFigures' "$osd" \ + || fail 'changing percentages do not use tabular figures' + +rg -Fq '$HOME/.config/quickshell/scripts/panama-osd' "$keybinds" \ + || fail 'keybinds do not use the deployed Panama OSD helper' +for action in \ + 'volume up 6' \ + 'volume down 6' \ + 'volume toggle' \ + 'microphone toggle' \ + 'volume up 1' \ + 'volume down 1' \ + 'media play-pause' \ + 'media next' \ + 'media previous' \ + 'media stop' \ + 'brightness up 5' \ + 'brightness down 5'; do + rg -Fq "osd(\"$action\")" "$keybinds" \ + || fail "keybind is not routed through panama-osd $action" +done + +printf 'osd ui contract: PASS\n'