Bound the notification app list, and give Focus a real editor
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Notifications
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
@@ -8,6 +9,10 @@ import qs.services
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
// A stand-in for a served notification. `urgency` and `expireTimeout` are
|
||||
// spelled out rather than left undefined: the effective-urgency override
|
||||
// and the banner-duration choice both read them, so a fixture that omits
|
||||
// them would be testing the undefined path instead of the ordinary one.
|
||||
function notification(idValue: int, desktopEntryValue: string, appNameValue: string): var {
|
||||
const closeHandlers = [];
|
||||
return {
|
||||
@@ -15,6 +20,9 @@ ShellRoot {
|
||||
desktopEntry: desktopEntryValue,
|
||||
appName: appNameValue,
|
||||
appIcon: "",
|
||||
urgency: NotificationUrgency.Normal,
|
||||
expireTimeout: -1,
|
||||
hints: ({}),
|
||||
transient: false,
|
||||
lastGeneration: false,
|
||||
tracked: false,
|
||||
@@ -42,6 +50,24 @@ ShellRoot {
|
||||
Notifs.fallbackAppRules = {};
|
||||
Notifs.rememberedApplications = {};
|
||||
DesktopPreferences.set("notificationAppRules", {});
|
||||
DesktopPreferences.set("criticalBreaksThrough", false);
|
||||
}
|
||||
|
||||
// One notification through the whole delivery path, reported as the four
|
||||
// counts that distinguish every outcome from every other: muted, filed
|
||||
// quietly, held by Do Not Disturb, or shown.
|
||||
function deliver(n: var): var {
|
||||
Notifs.handleNotification(n);
|
||||
return {
|
||||
tracked: n.tracked,
|
||||
history: Notifs.history.length,
|
||||
popups: Notifs.popups.length,
|
||||
unread: Notifs.unreadCount
|
||||
};
|
||||
}
|
||||
|
||||
function ruleKeys(): var {
|
||||
return Object.keys(DesktopPreferences.get("notificationAppRules"));
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
@@ -108,5 +134,115 @@ ShellRoot {
|
||||
function applications(): string {
|
||||
return JSON.stringify(Notifs.applications);
|
||||
}
|
||||
|
||||
// display: "history" is not muting. It has to reach history and the
|
||||
// unread count and stop short of the banner, which is the one
|
||||
// combination of the four counts that says so.
|
||||
function historyOnly(): string {
|
||||
root.reset();
|
||||
const seed = root.notification(10, "org.history.App.desktop", "History App");
|
||||
Notifs.handleNotification(seed);
|
||||
const appId = Notifs.notificationAppId(seed);
|
||||
|
||||
root.resetNotifications();
|
||||
Notifs.setAppRule(appId, { display: "history" });
|
||||
return JSON.stringify(root.deliver(
|
||||
root.notification(11, "org.history.App.desktop", "History App")));
|
||||
}
|
||||
|
||||
// Forgetting removes the key rather than resetting its fields, so the
|
||||
// next notification writes a fresh default rule and the row returns.
|
||||
function forgetting(): string {
|
||||
root.reset();
|
||||
const seed = root.notification(12, "org.forget.App.desktop", "Forget App");
|
||||
Notifs.handleNotification(seed);
|
||||
const appId = Notifs.notificationAppId(seed);
|
||||
|
||||
Notifs.setAppRule(appId, { sound: false, enabled: false });
|
||||
const before = root.ruleKeys().indexOf(appId) >= 0;
|
||||
Notifs.forgetApp(appId);
|
||||
const after = root.ruleKeys().indexOf(appId) >= 0;
|
||||
// Forgetting something with no rule is a no-op, not a write.
|
||||
const forgotUnknown = Notifs.forgetApp("org.never.Seen.desktop");
|
||||
|
||||
root.resetNotifications();
|
||||
Notifs.handleNotification(
|
||||
root.notification(13, "org.forget.App.desktop", "Forget App"));
|
||||
|
||||
return JSON.stringify({
|
||||
before: before,
|
||||
after: after,
|
||||
returned: root.ruleKeys().indexOf(appId) >= 0,
|
||||
soundAfterReturn: Notifs.appRule(appId).sound,
|
||||
forgotUnknown: forgotUnknown
|
||||
});
|
||||
}
|
||||
|
||||
// The override is one answer shared by the bell, the banner duration,
|
||||
// the breakthrough gate and the card. Only the duration is observable
|
||||
// from here, and it is the one that would silently keep the old value.
|
||||
function urgency(): string {
|
||||
root.reset();
|
||||
const n = root.notification(14, "org.urgent.App.desktop", "Urgent App");
|
||||
Notifs.handleNotification(n);
|
||||
const appId = Notifs.notificationAppId(n);
|
||||
|
||||
const claimed = Notifs.effectiveUrgency(n) === NotificationUrgency.Normal;
|
||||
const normalTimeout = Notifs.notificationTimeoutMs(n) === Settings.notificationTimeoutMs;
|
||||
|
||||
Notifs.setAppRule(appId, { urgency: "critical" });
|
||||
const escalated = Notifs.effectiveUrgency(n) === NotificationUrgency.Critical;
|
||||
const escalatedTimeout =
|
||||
Notifs.notificationTimeoutMs(n) === Settings.notificationTimeoutCriticalMs;
|
||||
|
||||
Notifs.setAppRule(appId, { urgency: "low" });
|
||||
const lowered = Notifs.effectiveUrgency(n) === NotificationUrgency.Low;
|
||||
|
||||
return JSON.stringify({
|
||||
claimed: claimed,
|
||||
escalated: escalated,
|
||||
lowered: lowered,
|
||||
normalTimeout: normalTimeout,
|
||||
escalatedTimeout: escalatedTimeout
|
||||
});
|
||||
}
|
||||
|
||||
// Do Not Disturb with its second exception. Off, everything is held;
|
||||
// on, exactly the effective-critical notifications get through -- and
|
||||
// an application told to be treated as critical is one of them.
|
||||
function breakthrough(): string {
|
||||
root.reset();
|
||||
const seed = root.notification(15, "org.critical.App.desktop", "Critical App");
|
||||
Notifs.handleNotification(seed);
|
||||
const appId = Notifs.notificationAppId(seed);
|
||||
|
||||
function attempt(idValue, claimedUrgency) {
|
||||
root.resetNotifications();
|
||||
Notifs.doNotDisturb = true;
|
||||
const n = root.notification(idValue, "org.critical.App.desktop", "Critical App");
|
||||
n.urgency = claimedUrgency;
|
||||
Notifs.handleNotification(n);
|
||||
return Notifs.popups.length;
|
||||
}
|
||||
|
||||
DesktopPreferences.set("criticalBreaksThrough", false);
|
||||
const held = attempt(16, NotificationUrgency.Critical);
|
||||
|
||||
DesktopPreferences.set("criticalBreaksThrough", true);
|
||||
const through = attempt(17, NotificationUrgency.Critical);
|
||||
const ordinary = attempt(18, NotificationUrgency.Normal);
|
||||
|
||||
Notifs.setAppRule(appId, { urgency: "critical" });
|
||||
const escalated = attempt(19, NotificationUrgency.Normal);
|
||||
|
||||
DesktopPreferences.set("criticalBreaksThrough", false);
|
||||
Notifs.doNotDisturb = false;
|
||||
return JSON.stringify({
|
||||
held: held,
|
||||
through: through,
|
||||
ordinary: ordinary,
|
||||
escalated: escalated
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,14 @@ service="$repo_dir/config/dot/quickshell/services/FocusModes.qml"
|
||||
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||
hook="$repo_dir/config/dot/quickshell/scripts/panama-gaming"
|
||||
gaming_page="$repo_dir/config/dot/quickshell/modules/settings/GamingPage.qml"
|
||||
focus_page="$repo_dir/config/dot/quickshell/modules/settings/FocusPage.qml"
|
||||
|
||||
fail() {
|
||||
printf 'focus modes contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for path in "$service" "$schema" "$hook" "$gaming_page"; do
|
||||
for path in "$service" "$schema" "$hook" "$gaming_page" "$focus_page"; do
|
||||
[[ -r "$path" ]] || fail "missing $path"
|
||||
done
|
||||
|
||||
@@ -158,7 +159,134 @@ grep -q 'function allows' "$service" \
|
||||
grep -q 'if (!mode || mode.silence !== true)' "$service" \
|
||||
|| fail 'exceptions are not scoped to an active silencing mode, so a manual Do Not Disturb could leak'
|
||||
|
||||
grep -q 'function toggleAllowed' "$page" \
|
||||
# The editor moved from the Notifications page to the Focus tab when the
|
||||
# category split in two, and the accordion row and its chips became their own
|
||||
# components. What must hold is unchanged: somewhere a person can reach, the
|
||||
# list the gate above consults can be edited. Asserted over the whole Focus
|
||||
# surface rather than the page file, so pulling a row into a component does
|
||||
# not read as the feature being deleted.
|
||||
focus_surface=("$focus_page" "$repo_dir"/config/dot/quickshell/modules/settings/Focus*.qml)
|
||||
|
||||
focus_surface_has() {
|
||||
grep -qF "$1" "${focus_surface[@]}"
|
||||
}
|
||||
|
||||
focus_surface_has 'FocusAllowChips' \
|
||||
|| fail 'nothing on the Focus page renders the exception chips, so the list is unreachable'
|
||||
focus_surface_has 'FocusModes.update(' \
|
||||
|| fail 'the Focus page never writes a mode back, so nothing it shows can be edited'
|
||||
focus_surface_has 'allow:' \
|
||||
|| fail 'the exception list cannot be edited, so the summary could claim something unreachable'
|
||||
# The chips are only honest if removing one writes the shorter list back.
|
||||
focus_surface_has 'toggled(' \
|
||||
|| fail 'the chips report nothing, so removing an application from a mode would change only the chip'
|
||||
|
||||
# ── 7. A mode you can make, name, order and retrigger ───────────────────────
|
||||
#
|
||||
# The five modes used to be whatever the schema shipped: the page could toggle
|
||||
# them and edit a schedule, and nothing else. Everything below is new surface
|
||||
# on a service whose entire job is to silence a machine, so each entry point is
|
||||
# pinned by name and the two that compute something are replayed.
|
||||
|
||||
for required in createMode removeMode renameMode moveMode setTriggerKind seedTrigger uniqueId hasMode; do
|
||||
grep -q "function $required" "$service" \
|
||||
|| fail "there is no $required(), so the editor would be calling something that does not exist"
|
||||
done
|
||||
|
||||
# Manual modes did not gain an activation path when the editor did. A manual
|
||||
# mode is never `activeMode`, because triggerActive() answers false for it and
|
||||
# `automatic` filters it out -- FocusSession owns the by-hand session, which is
|
||||
# rule 4 above. An editor that quietly made "manual" mean "on" would be a
|
||||
# second owner for Do Not Disturb wearing a new name.
|
||||
grep -q 'trigger.kind !== "manual"' "$service" \
|
||||
|| fail 'manual modes are no longer excluded from the automatic list, so switching one on would silence the machine behind FocusSession'
|
||||
grep -q '"manual" included: FocusSession owns those' "$service" \
|
||||
|| fail 'the manual fall-through lost the note explaining why it is not an activation path'
|
||||
|
||||
# Order is priority -- activeMode takes the first matching mode -- so reordering
|
||||
# is a behaviour change and the page has to say so rather than looking like a
|
||||
# cosmetic sort.
|
||||
grep -qi 'priority' "$focus_page" \
|
||||
|| fail 'the Focus page never says the order is the priority, so dragging a row reads as decoration'
|
||||
|
||||
PANAMA_FOCUS_SERVICE="$service" bun -e '
|
||||
const source = await Bun.file(process.env.PANAMA_FOCUS_SERVICE).text();
|
||||
|
||||
function fail(message) {
|
||||
console.error(`focus modes contract: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Same extraction the schedule arithmetic uses above, so these replay the
|
||||
// shipped function rather than a copy that can drift.
|
||||
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 seedTrigger = Function("kind", "fields", functionBody("seedTrigger"));
|
||||
const uniqueIdBody = Function("root", "name", functionBody("uniqueId"));
|
||||
const stubRoot = modes => ({
|
||||
modes: modes,
|
||||
hasMode: id => modes.some(mode => mode.id === id)
|
||||
});
|
||||
|
||||
function same(actual, expected, why) {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected))
|
||||
fail(`${why}: got ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`);
|
||||
}
|
||||
|
||||
// A trigger is seeded so that a mode is never saved meaning nothing. A
|
||||
// schedule with no days, or a workspace trigger with no workspace, is a mode
|
||||
// that silently never turns on -- the failure that is hardest to notice,
|
||||
// because nothing happens.
|
||||
same(seedTrigger("schedule", undefined),
|
||||
{ kind: "schedule", start: "22:00", end: "07:00", days: [0, 1, 2, 3, 4, 5, 6] },
|
||||
"a fresh schedule trigger");
|
||||
same(seedTrigger("schedule", { start: "23:15", days: [5] }),
|
||||
{ kind: "schedule", start: "23:15", end: "07:00", days: [5] },
|
||||
"a schedule keeps what it was handed and defaults the rest");
|
||||
same(seedTrigger("workspace", undefined), { kind: "workspace", id: 1 },
|
||||
"a fresh workspace trigger");
|
||||
same(seedTrigger("workspace", { id: "3" }), { kind: "workspace", id: 3 },
|
||||
"a workspace id arriving as text");
|
||||
same(seedTrigger("fullscreen", undefined), { kind: "fullscreen" },
|
||||
"a fullscreen trigger carries no fields");
|
||||
same(seedTrigger("game", undefined), { kind: "game" }, "a game trigger carries no fields");
|
||||
same(seedTrigger("manual", undefined), { kind: "manual" }, "a manual trigger carries no fields");
|
||||
|
||||
// An unknown kind is refused rather than stored. triggerActive() reads an
|
||||
// unknown kind as off, so storing one would be a mode that can never turn on.
|
||||
for (const kind of ["", "sometimes", undefined, null]) {
|
||||
if (seedTrigger(kind, undefined) !== null)
|
||||
fail(`an unknown trigger kind ${JSON.stringify(kind)} was seeded instead of refused`);
|
||||
}
|
||||
|
||||
// Ids are derived from the name and made unique by suffix. A duplicate id
|
||||
// would make removeMode and update() act on whichever came first.
|
||||
const existing = [{ id: "deep-work" }, { id: "deep-work-2" }, { id: "sleep" }];
|
||||
for (const [name, expected, why] of [
|
||||
["Reading", "reading", "a plain name"],
|
||||
[" Sleep ", "sleep-2", "a name whose id is taken"],
|
||||
["Deep work", "deep-work-3", "a name whose first two ids are taken"],
|
||||
["!!!", "mode", "a name with nothing usable in it"],
|
||||
["", "mode", "an empty name"]
|
||||
]) {
|
||||
const actual = uniqueIdBody(stubRoot(existing), name);
|
||||
if (actual !== expected)
|
||||
fail(`${why}: uniqueId gave ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`);
|
||||
}
|
||||
|
||||
console.log("focus modes API: ok");
|
||||
' >/dev/null || fail 'the mode editor API does not behave as the Focus page assumes'
|
||||
|
||||
printf 'focus modes contract: ok\n'
|
||||
|
||||
@@ -18,9 +18,31 @@ fail() {
|
||||
[[ -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 '
|
||||
card="$repo_dir/config/dot/quickshell/modules/notifications/NotificationCard.qml"
|
||||
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||
settings="$repo_dir/config/dot/quickshell/config/Settings.qml"
|
||||
|
||||
[[ -f "$card" ]] || fail 'notification card is missing'
|
||||
[[ -f "$schema" ]] || fail 'preference schema is missing'
|
||||
[[ -f "$settings" ]] || fail 'settings singleton is missing'
|
||||
|
||||
# The expanded row body lives in its own component now. It is part of the same
|
||||
# surface as the page, so the per-application assertions read both rather than
|
||||
# only the file that happens to hold the card today -- otherwise pulling a row
|
||||
# into a component reads as the feature being deleted. Scoped to the
|
||||
# Notification* components so a check cannot be satisfied by some unrelated
|
||||
# page that also happens to mention Notifs.
|
||||
page_family="$(find "$repo_dir/config/dot/quickshell/modules/settings" \
|
||||
-maxdepth 1 -name 'Notification*.qml' -not -name 'NotificationsPage.qml' \
|
||||
| sort | tr '\n' ':')"
|
||||
|
||||
SERVICE_PATH="$service" PAGE_PATH="$page" CARD_PATH="$card" \
|
||||
SCHEMA_PATH="$schema" SETTINGS_PATH="$settings" PAGE_FAMILY="$page_family" bun -e '
|
||||
const source = await Bun.file(process.env.SERVICE_PATH).text();
|
||||
const page = await Bun.file(process.env.PAGE_PATH).text();
|
||||
const card = await Bun.file(process.env.CARD_PATH).text();
|
||||
const schema = await Bun.file(process.env.SCHEMA_PATH).text();
|
||||
const settings = await Bun.file(process.env.SETTINGS_PATH).text();
|
||||
|
||||
function fail(message) {
|
||||
console.error(`notification application rules contract: ${message}`);
|
||||
@@ -55,25 +77,180 @@ for (const fixture of identityFixtures) {
|
||||
fail(`stable app identity expected ${fixture.expected}, got ${actual}`);
|
||||
}
|
||||
|
||||
const defaultRule = normalizedAppRule({});
|
||||
if (JSON.stringify(defaultRule) !== JSON.stringify({ enabled: true }))
|
||||
fail(`missing rule fields did not default safely: ${JSON.stringify(defaultRule)}`);
|
||||
// ── The rule shape ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// The rule grew from a single `enabled` bool to seven fields. Two properties
|
||||
// of that growth are what this checks, because both are invisible from the
|
||||
// code and both corrupt somebody stored preferences if they break:
|
||||
//
|
||||
// 1. Every new field is OPTIONAL. A rule written by the version that only
|
||||
// knew `enabled` is still a valid rule, and reads back with the new
|
||||
// fields at their defaults rather than as undefined.
|
||||
// 2. The field set is CLOSED. Anything unrecognized is dropped on the way
|
||||
// through -- including the two lock-screen keys that once shipped -- so
|
||||
// nothing can appear to be stored policy that nothing reads.
|
||||
//
|
||||
// The enum fields are checked for the same closure: a stored typo reads back
|
||||
// as the default, never as a third display mode or a fourth urgency.
|
||||
const ruleDefaults = {
|
||||
enabled: true,
|
||||
sound: true,
|
||||
display: "banners",
|
||||
urgency: "auto",
|
||||
lastSeenMs: 0,
|
||||
name: "",
|
||||
icon: ""
|
||||
};
|
||||
|
||||
// Old stored rules may still carry lock-screen fields from the era when the
|
||||
// page offered switches for them; normalization must drop dead fields, not
|
||||
// carry them forward as if something read them.
|
||||
const explicitRule = normalizedAppRule({ enabled: false, showOnLockScreen: false, showContentOnLockScreen: false });
|
||||
if (JSON.stringify(explicitRule) !== JSON.stringify({ enabled: false }))
|
||||
fail(`stale lock-screen fields were not dropped: ${JSON.stringify(explicitRule)}`);
|
||||
function sameRule(stored, expected, why) {
|
||||
const actual = normalizedAppRule(stored);
|
||||
const actualKeys = Object.keys(actual).sort().join(",");
|
||||
const expectedKeys = Object.keys(expected).sort().join(",");
|
||||
if (actualKeys !== expectedKeys)
|
||||
fail(`${why}: rule fields are [${actualKeys}], expected [${expectedKeys}]`);
|
||||
for (const key of Object.keys(expected)) {
|
||||
if (actual[key] !== expected[key])
|
||||
fail(`${why}: ${key} read back as ${JSON.stringify(actual[key])}, expected ${JSON.stringify(expected[key])}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const required of ["rememberApplication", "appRule", "setAppRule", "handleNotification"]) {
|
||||
sameRule({}, ruleDefaults, "an empty rule");
|
||||
sameRule(undefined, ruleDefaults, "a missing rule");
|
||||
sameRule(null, ruleDefaults, "a null rule");
|
||||
sameRule([], ruleDefaults, "a rule stored as an array");
|
||||
|
||||
// The back-compat case: exactly what the previous version wrote.
|
||||
sameRule({ enabled: true }, ruleDefaults, "an old enabled-only rule");
|
||||
sameRule({ enabled: false }, Object.assign({}, ruleDefaults, { enabled: false }),
|
||||
"an old muted rule");
|
||||
|
||||
// Stale lock-screen fields, plus a key from no version at all.
|
||||
sameRule(
|
||||
{ enabled: false, showOnLockScreen: false, showContentOnLockScreen: false, invented: 7 },
|
||||
Object.assign({}, ruleDefaults, { enabled: false }),
|
||||
"stale and unknown fields"
|
||||
);
|
||||
|
||||
// A fully specified rule round-trips unchanged.
|
||||
const fullRule = {
|
||||
enabled: false, sound: false, display: "history", urgency: "critical",
|
||||
lastSeenMs: 1700000000000, name: "Signal", icon: "signal-desktop"
|
||||
};
|
||||
sameRule(fullRule, fullRule, "a fully specified rule");
|
||||
sameRule({ urgency: "low" }, Object.assign({}, ruleDefaults, { urgency: "low" }),
|
||||
"a low urgency override");
|
||||
|
||||
// Closed vocabularies. A value outside them is a stored typo, not a mode.
|
||||
sameRule({ display: "shouty" }, ruleDefaults, "an unknown display mode");
|
||||
sameRule({ urgency: "urgent" }, ruleDefaults, "an unknown urgency override");
|
||||
sameRule({ display: "banners", urgency: "auto" }, ruleDefaults, "the defaults spelled out");
|
||||
|
||||
// lastSeenMs feeds relative-time rendering, so a non-number or a negative has
|
||||
// to read as "never seen" rather than as a date in 1970 or in the future.
|
||||
for (const bad of ["nope", "", null, NaN, -1, undefined]) {
|
||||
sameRule({ lastSeenMs: bad }, ruleDefaults, `lastSeenMs stored as ${JSON.stringify(bad)}`);
|
||||
}
|
||||
|
||||
for (const required of [
|
||||
"rememberApplication", "appRule", "setAppRule", "handleNotification",
|
||||
// New in the redesign: deleting a rule outright, and the one answer the
|
||||
// bell, the timeout, the breakthrough gate and the card all read.
|
||||
"forgetApp", "effectiveUrgency"
|
||||
]) {
|
||||
functionBody(required);
|
||||
}
|
||||
|
||||
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)
|
||||
// Forgetting an application must DELETE its rule, not write a rule that
|
||||
// happens to be all-defaults. The difference shows up on the settings list:
|
||||
// a defaulted rule keeps the row, a deleted one lets it disappear until the
|
||||
// application notifies again.
|
||||
const forget = functionBody("forgetApp");
|
||||
if (!/for \(const knownAppId of Object\.keys\(root\.appRules\)\)/.test(forget)
|
||||
|| !/knownAppId !== appId/.test(forget))
|
||||
fail("forgetApp does not rebuild the rule map without the forgotten key");
|
||||
if (!forget.includes("DesktopPreferences.set(\"notificationAppRules\", next)"))
|
||||
fail("forgetApp does not persist the deletion");
|
||||
|
||||
// rememberApplication stamps the bookkeeping fields on every notification.
|
||||
// Without lastSeenMs nothing can be sorted into "Recent"; without the cached
|
||||
// name and icon an application that is not installed here draws as a raw id.
|
||||
const remember = functionBody("rememberApplication");
|
||||
for (const stamped of ["lastSeenMs", "name:", "icon:"]) {
|
||||
if (!remember.includes(stamped))
|
||||
fail(`rememberApplication does not stamp ${stamped}`);
|
||||
}
|
||||
if (!remember.includes("Date.now()"))
|
||||
fail("rememberApplication stamps a last-seen time that is not the clock");
|
||||
|
||||
// ── effectiveUrgency and its four consumers ────────────────────────────────
|
||||
//
|
||||
// The override is only worth having if everything that asks about urgency
|
||||
// asks the same function. Reading notification.urgency directly anywhere in
|
||||
// this list would mean "treat as critical" changed the colour but not the
|
||||
// sound, or the sound but not the banner duration.
|
||||
const effective = functionBody("effectiveUrgency");
|
||||
for (const required of ["rule.urgency === \"low\"", "rule.urgency === \"critical\"", "notification.urgency"]) {
|
||||
if (!effective.includes(required))
|
||||
fail(`effectiveUrgency is missing ${required}`);
|
||||
}
|
||||
|
||||
const bell = functionBody("playBell");
|
||||
if (!bell.includes("root.effectiveUrgency(notification) === NotificationUrgency.Low"))
|
||||
fail("the bell reads the claimed urgency rather than the effective one, so treat-as-low would still chime");
|
||||
// The per-application sound switch is narrower than the enabled switch: the
|
||||
// notification still arrives and still shows, it just makes no noise.
|
||||
if (!/appRule\(root\.notificationAppId\(notification\)\)\.sound/.test(bell))
|
||||
fail("the bell does not consult the per-application sound switch");
|
||||
|
||||
const timeout = functionBody("notificationTimeoutMs");
|
||||
if (!timeout.includes("root.effectiveUrgency(notification) === NotificationUrgency.Critical"))
|
||||
fail("the banner duration reads the claimed urgency, so treat-as-critical would look critical but vanish at the normal timeout");
|
||||
|
||||
if (!card.includes("Notifs.effectiveUrgency("))
|
||||
fail("the notification card draws its critical edge from the claimed urgency rather than the effective one");
|
||||
|
||||
// ── The Do Not Disturb gate ────────────────────────────────────────────────
|
||||
//
|
||||
// It had exactly one exception (a focus mode allow list) and now has two. The
|
||||
// literal is asserted rather than a loosened shape: a bare if (!doNotDisturb)
|
||||
// would mean neither exception is consulted, and anything wider would let a
|
||||
// manual Do Not Disturb be overridden by something nobody switched on.
|
||||
const handler = functionBody("handleNotification");
|
||||
if (!handler.includes("!root.doNotDisturb || FocusModes.allows(appId) || breaksThrough"))
|
||||
fail("the popup gate is no longer Do Not Disturb plus its two named exceptions");
|
||||
if (!handler.includes("Settings.criticalBreaksThrough")
|
||||
|| !handler.includes("root.effectiveUrgency(notification) === NotificationUrgency.Critical"))
|
||||
fail("the breakthrough exception is not the preference ANDed with the effective critical urgency");
|
||||
|
||||
// History-only files the notification and stops. It must reach pushHistory
|
||||
// and the unread count -- that is the whole point of it -- and must not reach
|
||||
// the popup list or playBell.
|
||||
if (!handler.includes("rule.display === \"history\""))
|
||||
fail("the delivery path never consults the per-application display mode");
|
||||
const historyOnlyAt = handler.indexOf("const historyOnly");
|
||||
const pushAt = handler.indexOf("root.pushHistory(notification)");
|
||||
const popupAt = handler.indexOf("root.popups = [notification].concat(root.popups)");
|
||||
if (historyOnlyAt === -1 || pushAt === -1 || popupAt === -1)
|
||||
fail("could not locate the history-only branch, the history push and the popup push");
|
||||
if (!(pushAt < popupAt))
|
||||
fail("history-only would skip history as well as the banner, which is not what it means");
|
||||
if (!/if \(historyOnly\)/.test(handler))
|
||||
fail("history-only is not a branch that bypasses the banner and the bell together");
|
||||
|
||||
// The preference behind the gate, where the search index and the docs find it.
|
||||
if (!/key: "criticalBreaksThrough", type: "bool", def: false/.test(schema))
|
||||
fail("criticalBreaksThrough is missing from the schema, or does not default to off");
|
||||
if (!/key: "criticalBreaksThrough"[\s\S]{0,200}?group: "notifications"/.test(schema))
|
||||
fail("criticalBreaksThrough is not in the notifications group, so it would route to the wrong page");
|
||||
if (!settings.includes("criticalBreaksThrough: DesktopPreferences.get(\"criticalBreaksThrough\")"))
|
||||
fail("Settings does not expose criticalBreaksThrough, so the gate reads nothing");
|
||||
|
||||
// A muted application is rejected before anything is tracked, filed, counted
|
||||
// or shown. Everything past this point in the handler costs something, so the
|
||||
// order is the assertion.
|
||||
const trackedAt = handler.indexOf("notification.tracked = true");
|
||||
const mutedAt = handler.indexOf("if (!rule.enabled)");
|
||||
if (trackedAt === -1 || mutedAt === -1 || mutedAt > trackedAt)
|
||||
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");
|
||||
@@ -83,18 +260,14 @@ 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");
|
||||
// Every rule in the map is rewritten through appRule() on the way out, so a
|
||||
// write can never persist a shape normalization would have rejected -- which
|
||||
// is what makes the closed field set above hold for stored data and not only
|
||||
// for what is read back.
|
||||
if (!source.includes("next[knownAppId] = root.appRule(knownAppId)"))
|
||||
fail("persisted rules are not normalized to the required three-field shape");
|
||||
fail("persisted rules are not normalized on the way to disk");
|
||||
if (!source.includes("root.fallbackAppRules = next"))
|
||||
fail("missing-schema preference writes do not retain an in-memory fallback");
|
||||
// Do Not Disturb still gates popups; it gained exactly one exception, and the
|
||||
// assertion names that exception rather than being loosened. A bare
|
||||
// if (!doNotDisturb) would mean the allow list of the active focus mode is
|
||||
// never consulted. Anything wider would let a manual Do Not Disturb be
|
||||
// overridden. No apostrophes here: this block is inside a single-quoted shell
|
||||
// string, and one closes it.
|
||||
if (!source.includes("if (!root.doNotDisturb || FocusModes.allows("))
|
||||
fail("Do Not Disturb no longer gates popups with a focus-mode exception");
|
||||
for (const required of [
|
||||
"DesktopEntries.applications.values",
|
||||
"DesktopEntries.byId(appId)",
|
||||
@@ -104,6 +277,15 @@ for (const required of [
|
||||
fail(`persisted desktop entry ids are not reactively resolved through ${required}`);
|
||||
}
|
||||
|
||||
// The per-application controls are asserted over the page AND the components
|
||||
// it delegates rows to. The rebuild moved the expanded row body into its own
|
||||
// component, and pinning the page file alone would have made that refactor
|
||||
// look like the feature being deleted.
|
||||
const familyPaths = (process.env.PAGE_FAMILY ?? "").split(":").filter(path => path !== "");
|
||||
const familyText = [page]
|
||||
.concat(await Promise.all(familyPaths.map(path => Bun.file(path).text())))
|
||||
.join("\n");
|
||||
|
||||
for (const required of [
|
||||
"Notifs.applications",
|
||||
// Read through a binding on Notifs.appRule rather than a stored copy, so a
|
||||
@@ -114,15 +296,39 @@ for (const required of [
|
||||
".enabled",
|
||||
"Notifs.setAppRule"
|
||||
]) {
|
||||
if (!page.includes(required))
|
||||
if (!familyText.includes(required))
|
||||
fail(`settings page is missing ${required}`);
|
||||
}
|
||||
|
||||
// The four rules the redesign added are only real if the page offers all of
|
||||
// them. Each was shipped as a field on the rule before the UI existed, and a
|
||||
// stored field nothing can reach is the same bug the lock-screen pair was.
|
||||
for (const [required, why] of [
|
||||
["Notifs.forgetApp(", "no way to forget an application, so a rule is permanent once written"],
|
||||
["sound:", "no per-application sound switch"],
|
||||
["display:", "no banners-versus-history choice"],
|
||||
["urgency:", "no per-application urgency override"]
|
||||
]) {
|
||||
if (!familyText.includes(required))
|
||||
fail(`the application rules card offers ${why} (missing ${required})`);
|
||||
}
|
||||
// Wording the spec pins, because it is the only place the consequence of
|
||||
// forgetting is explained.
|
||||
if (!familyText.includes("Forget this app"))
|
||||
fail("the expanded row has no Forget this app action");
|
||||
if (!/returns on its next notification/.test(familyText))
|
||||
fail("Forget this app does not say the rule comes back when the application notifies again");
|
||||
|
||||
// The Quiet card owns the breakthrough switch, and it belongs on the page the
|
||||
// notifications schema group routes to.
|
||||
if (!page.includes("criticalBreaksThrough"))
|
||||
fail("the Notifications page does not offer criticalBreaksThrough, which is in its schema group");
|
||||
|
||||
// The lock screen is hyprlock, which cannot render notifications. Two per-app
|
||||
// lock-screen switches once shipped anyway, controlling nothing -- the page
|
||||
// must not grow controls the session cannot honor.
|
||||
for (const forbidden of ["showOnLockScreen", "showContentOnLockScreen"]) {
|
||||
if (page.includes(forbidden))
|
||||
if (familyText.includes(forbidden))
|
||||
fail(`settings page offers ${forbidden}, which nothing in a hyprlock session reads`);
|
||||
}
|
||||
|
||||
@@ -149,13 +355,13 @@ 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
|
||||
# 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"
|
||||
# The rule store is a shipped schema key now. This contract used to graft a
|
||||
# temporary one into its copy of the schema, because the runtime half was
|
||||
# written before the key landed; grafting it today would define the key twice.
|
||||
rg -q 'key: "notificationAppRules", type: "json"' "$config_path/config/PreferenceSchema.qml" \
|
||||
|| fail 'temporary schema integration key was not installed'
|
||||
|| fail 'the schema does not define notificationAppRules, so nothing here can persist'
|
||||
rg -q 'key: "criticalBreaksThrough", type: "bool"' "$config_path/config/PreferenceSchema.qml" \
|
||||
|| fail 'the schema does not define criticalBreaksThrough, so the breakthrough fixture would prove nothing'
|
||||
|
||||
mapfile -t dbus_info < <(dbus-daemon --session --fork --print-address=1 --print-pid=1)
|
||||
bus_address="${dbus_info[0]:-}"
|
||||
@@ -190,23 +396,62 @@ start_harness() {
|
||||
|
||||
start_harness
|
||||
exercise="$(qs_for_test ipc call notification-app-rules-test exercise)"
|
||||
# The stored rule is checked field by field rather than against a literal,
|
||||
# because lastSeenMs is a wall clock: it has to be a real stamp (so "Recent"
|
||||
# can sort by it) and cannot be written down here.
|
||||
jq -e '
|
||||
.appId == "org.signal.Signal.desktop" and
|
||||
.initialRules == {
|
||||
"org.signal.Signal.desktop": { enabled: true }
|
||||
} and
|
||||
(.initialRules | keys) == ["org.signal.Signal.desktop"] and
|
||||
(.initialRules["org.signal.Signal.desktop"] | keys | sort) ==
|
||||
["display", "enabled", "icon", "lastSeenMs", "name", "sound", "urgency"] and
|
||||
(.initialRules["org.signal.Signal.desktop"] |
|
||||
.enabled == true and .sound == true and .display == "banners" and
|
||||
.urgency == "auto" and .name == "Signal" and
|
||||
(.lastSeenMs | type) == "number" and .lastSeenMs > 0 and
|
||||
(.icon | type) == "string") and
|
||||
.muted == { tracked: false, history: 0, popups: 0, unread: 0 } and
|
||||
.dnd == { tracked: true, history: 1, popups: 0, unread: 1 } and
|
||||
.fallback == {
|
||||
id: "Fallback Terminal",
|
||||
application: { id: "Fallback Terminal", name: "Fallback Terminal" }
|
||||
}
|
||||
.fallback.id == "Fallback Terminal" and
|
||||
.fallback.application.id == "Fallback Terminal" and
|
||||
.fallback.application.name == "Fallback Terminal"
|
||||
' <<<"$exercise" >/dev/null || fail "runtime notification policy fixture failed: $exercise"
|
||||
|
||||
persisted="$(qs_for_test ipc call notification-app-rules-test persist)"
|
||||
# "History only" is the quiet filing cabinet: it still lands in history and
|
||||
# still counts as unread, and that is the whole of it -- no banner. The counts
|
||||
# distinguish it from muting, which is the mistake it would be easy to ship.
|
||||
history_only="$(qs_for_test ipc call notification-app-rules-test historyOnly)"
|
||||
jq -e '. == { tracked: true, history: 1, popups: 0, unread: 1 }' <<<"$history_only" >/dev/null \
|
||||
|| fail "a history-only application did not file quietly: $history_only"
|
||||
|
||||
# Forgetting deletes the key. It comes back on the next notification at its
|
||||
# defaults -- which is what makes forgetting an undo rather than a mute.
|
||||
forgotten="$(qs_for_test ipc call notification-app-rules-test forgetting)"
|
||||
jq -e '. == {
|
||||
"org.persist.App.desktop": { enabled: false }
|
||||
}' <<<"$persisted" >/dev/null || fail "runtime persistence fixture wrote the wrong shape: $persisted"
|
||||
before: true, after: false, returned: true, soundAfterReturn: true, forgotUnknown: false
|
||||
}' <<<"$forgotten" >/dev/null || fail "forgetApp did not delete and restore a rule: $forgotten"
|
||||
|
||||
# The urgency override is one answer, read by everything. The timeouts prove
|
||||
# the banner duration followed it and not only the colour.
|
||||
urgency="$(qs_for_test ipc call notification-app-rules-test urgency)"
|
||||
jq -e '. == {
|
||||
claimed: true, escalated: true, lowered: true,
|
||||
normalTimeout: true, escalatedTimeout: true
|
||||
}' <<<"$urgency" >/dev/null || fail "the per-application urgency override was not honored: $urgency"
|
||||
|
||||
# The one exception to Do Not Disturb that is not a focus mode. Off by
|
||||
# default; on, it lets through exactly the effective-critical notifications
|
||||
# and nothing else.
|
||||
breakthrough="$(qs_for_test ipc call notification-app-rules-test breakthrough)"
|
||||
jq -e '. == { held: 0, through: 1, ordinary: 0, escalated: 1 }' <<<"$breakthrough" >/dev/null \
|
||||
|| fail "the critical breakthrough gate is wrong: $breakthrough"
|
||||
|
||||
persisted="$(qs_for_test ipc call notification-app-rules-test persist)"
|
||||
jq -e '
|
||||
(keys) == ["org.persist.App.desktop"] and
|
||||
(.["org.persist.App.desktop"] |
|
||||
.enabled == false and .sound == true and .display == "banners" and
|
||||
.urgency == "auto" and (.lastSeenMs | type) == "number" and .lastSeenMs > 0)
|
||||
' <<<"$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
|
||||
|
||||
@@ -23,6 +23,7 @@ routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml"
|
||||
modules="$repo_dir/config/dot/quickshell/modules"
|
||||
dock_menu="$modules/dock/DockContextMenu.qml"
|
||||
notification_card="$modules/notifications/NotificationCard.qml"
|
||||
gaming_page="$modules/settings/GamingPage.qml"
|
||||
osd="$modules/osd/Osd.qml"
|
||||
|
||||
fail() {
|
||||
@@ -79,6 +80,18 @@ grep -qF 'ShellState.openSettings("notifications")' "$notification_card" \
|
||||
grep -qF 'Popover {' "$notification_card" \
|
||||
|| fail 'notification Settings action is not contained in an overflow menu'
|
||||
|
||||
# The Gaming page does not own the Game Mode switch any more -- a focus mode
|
||||
# does -- so its only honest affordance is a way to reach that mode. It used to
|
||||
# assign ShellState.settingsPage directly, which skipped resolve() entirely and
|
||||
# so skipped the guard above; and it pointed at "notifications", which was
|
||||
# right only while focus modes rendered on that page.
|
||||
grep -qF 'Open Focus' "$gaming_page" \
|
||||
|| fail 'the Gaming page no longer offers a way to reach the mode that replaced its switch'
|
||||
grep -qF 'ShellState.openSettings("focus")' "$gaming_page" \
|
||||
|| fail 'the Gaming page does not open the Focus tab through openSettings(), so this contract cannot see where it lands'
|
||||
grep -qF 'ShellState.settingsPage = ' "$gaming_page" \
|
||||
&& fail 'the Gaming page still sets the settings page by hand, bypassing SettingsRoutes.resolve()'
|
||||
|
||||
grep -qF 'acceptedButtons: Qt.RightButton' "$osd" \
|
||||
|| fail 'OSD does not accept its contextual secondary click'
|
||||
grep -qF 'ShellState.openSettings("accessibility")' "$osd" \
|
||||
|
||||
@@ -9,7 +9,7 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
pages=(Home MyHome Phone Displays Connectivity Bar Dock ControlCenter Tiling Workspaces Sync Sound Dictation Notifications ScreenIntelligence Health About)
|
||||
pages=(Home MyHome Phone Displays Connectivity Bar Dock ControlCenter Tiling Workspaces Sync Sound Dictation Notifications Focus ScreenIntelligence Health About)
|
||||
for page in "${pages[@]}"; do
|
||||
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
||||
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
|
||||
@@ -311,7 +311,7 @@ shell_pid="$harness_pid"
|
||||
# four different categories, and the page the tab strip was introduced for.
|
||||
# Routing to a tab must land on that tab, not on whatever its category opens
|
||||
# first, which is the failure the SettingsRoutes resolution could introduce.
|
||||
pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications screen-intelligence shortcuts services manual about)
|
||||
pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications focus screen-intelligence shortcuts services manual about)
|
||||
for page in "${pages[@]}"; do
|
||||
qs_for_test ipc call settings page "$page" >/dev/null
|
||||
for _ in $(seq 1 20); do
|
||||
|
||||
Reference in New Issue
Block a user