Make every control tell the truth

The audit's first tier, in one change: every case found where the interface
asserted something the system did not do.

Twenty-one compositor-owned preferences -- the whole Mouse & Touchpad page,
plus window layout, snapping, dim-inactive and the magnifier -- had the live
half (hyprctl eval) and not the config-time half, so they quietly reverted on
every hyprctl reload. All 70 hypr-backed keys now have a prefs.get() in the
Lua, and hypr-prefs-contract pins both the presence and that the Lua fallback
equals the schema default, which is how the touchpad page misreported natural
scrolling on first boot.

The idle generator fell back from unwritten battery keys to the AC values
while the Power page displayed the schema defaults: a fresh laptop showed
"suspend at 20 minutes" and generated no suspend listener, then discharged to
zero in a bag. Unwritten keys now use the defaults the page shows
(idle-defaults-contract pins generator to schema; idle-config-contract
re-pinned to the new rule with the tradeoff recorded), and change-settings
enables managed idle on any machine with a battery -- without starting
hypridle in whatever session the installer runs under.

The per-app lock-screen notification switches wrote fields nothing read:
hyprlock cannot render notifications. Removed, with the rule model shrunk to
{enabled}, stale stored fields dropped at normalization, and the contract now
forbidding the page from growing lock-screen switches it cannot honor.

The battery warning thresholds were searchable, documented as "Found on
Power & Lock", and rendered nowhere -- and crossing the low threshold changed
only a glyph's color. Both sliders now exist where search was already sending
people, and low battery publishes a real notification at important priority.

Three handoffs opened GNOME panels that are inert in a Hyprland session. The
keyboard handoff is gone (that panel writes gsettings nothing here reads, and
the working controls sat on the same page); Connectivity gains a Wi-Fi row
that opens GNOME's actual Wi-Fi panel -- hidden SSIDs and 802.1X finally have
a road -- beside the network row that legitimately drives NetworkManager; the
universal-access handoff is gone, its few working toggles being controls this
app already owns. And the accessibility page now gives the true reason sticky
keys are missing: each Wayland compositor implements its own and Hyprland
does not yet -- not "an X11 feature with no Wayland equivalent," which sent
people to the wrong conclusion about the platform.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
This commit is contained in:
Gabriel Brown
2026-08-23 11:43:39 -04:00
parent dfc0c49877
commit 3d21e20041
18 changed files with 321 additions and 176 deletions
+1 -1
View File
@@ -136,7 +136,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
156 of them, under `tests/`. Run the lot, or a subset by pattern:
158 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh
panama test # everything
+29 -2
View File
@@ -35,12 +35,39 @@ hl.config({
-- Still focus-follows-pointer, just less twitchy.
mouse_refocus = false,
-- Flat pointer response, no acceleration. Matters for gaming.
-- Flat pointer response by default, no acceleration. Matters for
-- gaming; Settings offers adaptive for people who want it back.
sensitivity = prefs.get("pointerSensitivity", 0),
accel_profile = "flat",
accel_profile = prefs.get("accelProfile", "flat"),
natural_scroll = prefs.get("naturalScroll", false),
scroll_factor = prefs.get("scrollFactor", 1.0),
left_handed = prefs.get("leftHanded", false),
-- Clicking a floating window raises and focuses it.
float_switch_override_focus = 2,
-- Every touchpad preference Settings offers is read here as well as
-- applied live: the live half (hyprctl eval) reaches a running
-- compositor, and THIS half is what survives `hyprctl reload` and the
-- gap before the shell starts. A schema `hypr:` entry without a
-- prefs.get() here is a control that quietly reverts -- the whole
-- Mouse & Touchpad page once had exactly that bug. Defaults must
-- match PreferenceSchema's; the hypr-prefs-contract checks both.
touchpad = {
tap_to_click = prefs.get("touchpadTapToClick", true),
natural_scroll = prefs.get("touchpadNaturalScroll", true),
disable_while_typing = prefs.get("touchpadDisableWhileTyping", true),
scroll_factor = prefs.get("touchpadScrollFactor", 1.0),
drag_lock = prefs.getInt("touchpadDragLock", 0),
middle_button_emulation = prefs.get("touchpadMiddleButtonEmulation", false),
},
},
-- Tuning for the three-finger gestures registered below.
gestures = {
workspace_swipe_distance = prefs.getInt("swipeDistance", 300),
workspace_swipe_invert = prefs.get("swipeInvert", true),
},
})
+16 -3
View File
@@ -68,10 +68,10 @@ hl.config({
-- Harmless on its own; tearing only happens where a rule opts in.
allow_tearing = true,
layout = "dwindle",
layout = prefs.get("windowLayout", "dwindle"),
snap = {
enabled = true,
enabled = prefs.get("windowSnapping", true),
window_gap = prefs.getInt("snapWindowGap", 10),
monitor_gap = prefs.getInt("snapMonitorGap", 10),
respect_gaps = prefs.get("snapRespectGaps", false),
@@ -88,6 +88,10 @@ hl.config({
fullscreen_opacity = prefs.get("fullscreenOpacity", 1.0),
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
-- Accessibility: dim every window but the focused one.
dim_inactive = prefs.get("dimInactive", false),
dim_strength = prefs.get("dimStrength", 0.5),
blur = {
enabled = prefs.get("blurEnabled", true),
size = prefs.get("blurSize", 8),
@@ -150,6 +154,7 @@ hl.config({
-- to how the Forge extension behaved on GNOME.
preserve_split = prefs.get("preserveSplit", true),
smart_resizing = true,
force_split = prefs.getInt("forceSplit", 0),
},
-- Only in effect when the tiling layout is "master". Panama ships dwindle,
@@ -193,7 +198,11 @@ hl.config({
allow_session_lock_restore = true,
-- Don't let apps steal focus by shouting; matches GNOME's behavior.
focus_on_activate = false,
focus_on_activate = prefs.get("focusOnActivate", false),
-- Whether pointing at another monitor is enough to move focus there,
-- or it takes a click. GNOME moves on pointer; both are offered.
mouse_move_focuses_monitor = prefs.get("mouseMoveFocusesMonitor", true),
-- Window swallowing: a terminal hides itself while a graphical
-- application launched from it is open, and comes back when that
@@ -237,6 +246,10 @@ hl.config({
-- Fade the cursor out after 4s of no movement, like GNOME does.
inactive_timeout = prefs.get("cursorInactiveTimeout", 4),
-- The accessibility magnifier. 1.0 = off.
zoom_factor = prefs.get("magnifierFactor", 1.0),
zoom_rigid = prefs.get("magnifierRigid", false),
},
ecosystem = {
@@ -1446,7 +1446,8 @@ Singleton {
},
// ── Per-application notification rules ──────────────────────────────
// { "<appId>": { enabled, showOnLockScreen, showContentOnLockScreen } }
// { "<appId>": { enabled } } -- lock-screen fields from older rules are
// dropped at normalization; hyprlock cannot render notifications.
//
// Absent means "no rule", which is not the same as a rule that allows
// everything: a new application must be able to notify without needing
@@ -62,32 +62,26 @@ SettingsPage {
SliderRow { setting: "dimStrength"; divider: false }
}
// What this session genuinely cannot do, said plainly.
//
// Sticky keys, slow keys, bounce keys and mouse keys are AccessX, which is
// an X11 SERVER feature. XKB under Wayland has no accessx option group at
// all, and Hyprland does not implement one. The compositor will accept
// "accessx:enable" as a keyboard option and store it, and nothing will ever
// act on it -- so there is no switch here, and pointing at GNOME's panel
// would be no better, since the daemon that applies those keys is not
// running either.
// What this session genuinely cannot do, said plainly -- and for the
// right reason. On Wayland there is no protocol for sticky, slow or
// bounce keys: each compositor implements its own (mutter does, which is
// how GNOME has them on Wayland), and Hyprland does not yet. An earlier
// version blamed "X11 feature with no Wayland equivalent", which sent
// anyone who needs sticky keys to the wrong conclusion about the whole
// platform. There is also deliberately no handoff to GNOME's
// universal-access panel: its toggles are applied by GNOME Shell, and
// the few that work through plain gsettings (cursor size, text scale,
// high contrast) are owned by the controls above on this very page.
SettingsCard {
title: "Keyboard accessibility"
subtitle: "Sticky, slow and bounce keys are an X11 feature with no Wayland equivalent, so they are unavailable in this session. Offering them here would store a preference that nothing acts on."
subtitle: "Sticky, slow and bounce keys are implemented by each Wayland compositor for itself; Hyprland does not implement them yet, so they are unavailable in this session. Offering switches here would store preferences nothing acts on."
ActionRow {
label: "Screen reader"
detail: "Orca reads the screen aloud and works over the accessibility bus, which does run here"
action: "Start Orca"
onTriggered: SystemSettings.openApplication("orca")
}
ActionRow {
label: "GNOME accessibility settings"
detail: "For the parts GNOME's own stack still owns"
action: "Open"
divider: false
onTriggered: SystemSettings.openGnomePanel("universal-access")
onTriggered: SystemSettings.openApplication("orca")
}
}
@@ -185,11 +185,24 @@ SettingsPage {
SettingsCard {
title: "Owned by Fedora"
subtitle: "VPNs and per-connection routing are still configured by GNOME's panel, which is installed and searchable. Printers and online accounts have their own pages here."
// These two panels drive NetworkManager over D-Bus, which is why they
// work in this session when most GNOME panels do not. They are split
// because GNOME splits them: "network" is wired, VPN and proxies, and
// does not contain Wi-Fi -- the one panel used to point everyone
// there, so the road to a hidden SSID or eduroam ended on a page
// without Wi-Fi on it.
subtitle: "Wired, VPN and Wi-Fi connection editing stay with GNOME's panels, which drive the same NetworkManager this page reads. Printers and online accounts have their own pages here."
ActionRow {
label: "Wi-Fi networks"
detail: "Hidden networks, enterprise (802.1X) logins, and per-network settings"
action: "Open"
onTriggered: SystemSettings.openGnomePanel("wifi")
}
ActionRow {
label: "Network connections"
detail: "VPN, proxies, and per-connection settings"
detail: "VPN, proxies, and wired connection settings"
action: "Open"
divider: false
onTriggered: SystemSettings.openGnomePanel("network")
@@ -7,7 +7,6 @@ SettingsPage {
// Only one application is expanded at a time; the point is a card you
// can read, not twenty open at once.
property string expandedApp: ""
// Which focus mode is open for editing. One at a time, like the app rules.
property string expandedMode: ""
@@ -117,88 +116,22 @@ SettingsPage {
readonly property var app: appEntry.modelData
readonly property var rule: Notifs.appRule(appEntry.app.id)
readonly property bool open: root.expandedApp === String(appEntry.app.id)
// What the two lock-screen switches add up to, so the common
// case -- reading rather than changing -- needs no interaction.
// Every app repeated both switch labels verbatim before this,
// which made twenty apps sixty rows of identical sentences.
readonly property string summary: {
if (!appEntry.rule.enabled)
return "Notifications off";
if (!appEntry.rule.showOnLockScreen)
return "On · hidden on the lock screen";
return appEntry.rule.showContentOnLockScreen
? "On · lock screen shows content"
: "On · lock screen shows the sender only";
}
width: parent.width
SettingRow {
width: parent.width
label: appEntry.app.name
// The identifier is only worth the space while the app is
// open, which is the only time it disambiguates anything.
detail: appEntry.open ? String(appEntry.app.id) : appEntry.summary
activatable: true
divider: !appEntry.open
controlWidth: 92
onActivated: root.expandedApp = appEntry.open ? "" : String(appEntry.app.id)
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 11
detail: appEntry.rule.enabled ? String(appEntry.app.id) : "Notifications off"
divider: true
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: appEntry.rule.enabled
onToggled: value => Notifs.setAppRule(appEntry.app.id, { enabled: value })
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: appEntry.open ? "\u25B4" : "\u25BE"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
SettingRow {
width: parent.width
visible: appEntry.open
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: appEntry.rule.showOnLockScreen
onToggled: value => Notifs.setAppRule(appEntry.app.id, { showOnLockScreen: value })
}
}
SettingRow {
width: parent.width
visible: appEntry.open
label: "Show content on lock screen"
detail: "Show message details when this app is visible there"
divider: false
controlWidth: 48
// Meaningless unless the app reaches the lock screen at all.
opacity: appEntry.rule.showOnLockScreen ? 1 : 0.45
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
enabled: appEntry.rule.showOnLockScreen
checked: appEntry.rule.showContentOnLockScreen
onToggled: value => Notifs.setAppRule(appEntry.app.id, { showContentOnLockScreen: value })
}
}
}
}
@@ -46,6 +46,15 @@ SettingsPage {
return "Plugged in, not charging";
return "On battery";
}
}
// The two points at which the desktop starts telling you. These were
// in the schema and reachable from settings search long before any
// page rendered them -- search delivered people to this card and the
// controls were not here.
SliderRow { setting: "batteryLowPercent" }
SliderRow {
setting: "batteryCriticalPercent"
divider: Battery.chargeLimitSupported
}
@@ -111,17 +111,12 @@ SettingsPage {
ToggleRow { setting: "numlockByDefault"; divider: false }
}
SettingsCard {
title: "Hardware input"
ActionRow {
label: "Mouse, touchpad, and keyboard devices"
detail: "Device-specific settings stay with Fedora's hardware-backed panels"
action: "Open keyboard"
divider: false
onTriggered: SystemSettings.openGnomePanel("keyboard")
}
}
// Deliberately no handoff to GNOME's keyboard panel here. That panel
// writes org.gnome.desktop input-source and shortcut gsettings, which
// nothing in a Hyprland session reads -- so the button looked like the
// escape hatch for layouts and did nothing, while the controls that DO
// work sat further up this same page. A handoff to an inert panel is a
// dead end wearing a button.
// One card per group, built from what the compositor actually has bound.
//
+26 -10
View File
@@ -71,16 +71,24 @@ load() {
suffix="Battery"
fi
# The battery variants fall back to their AC counterparts rather than to a
# constant, so a machine whose battery keys were never written still gets
# coherent behavior instead of the schema's shipped shorter timings
# overriding a deliberately long AC setting.
blank_min="$(clamp_int "$(read_setting "screenBlankMinutes$suffix" \
"$(read_setting screenBlankMinutes 5)")" 0 120 5)"
lock_min="$(clamp_int "$(read_setting "lockMinutes$suffix" \
"$(read_setting lockMinutes 10)")" 0 240 10)"
suspend_min="$(clamp_int "$(read_setting "suspendMinutes$suffix" \
"$(read_setting suspendMinutes 0)")" 0 480 0)"
# An unwritten key falls back to ITS OWN schema default, never to the
# other power source's value. The battery keys once fell back to their AC
# counterparts, which sounded protective and produced a lie instead: the
# Power page shows the schema default (suspend at 20) for an unwritten
# battery key, while this generator quietly used the AC value (never), so
# a fresh laptop displayed one behavior and shipped another -- and
# discharged to zero in a bag. Whatever the sliders show is what must be
# generated; these fallbacks are pinned to the schema by
# tests/hypr/idle-defaults-contract.
if [[ "$suffix" == "Battery" ]]; then
blank_min="$(clamp_int "$(read_setting screenBlankMinutesBattery 2)" 0 120 2)"
lock_min="$(clamp_int "$(read_setting lockMinutesBattery 5)" 0 240 5)"
suspend_min="$(clamp_int "$(read_setting suspendMinutesBattery 20)" 0 480 20)"
else
blank_min="$(clamp_int "$(read_setting screenBlankMinutes 5)" 0 120 5)"
lock_min="$(clamp_int "$(read_setting lockMinutes 10)" 0 240 10)"
suspend_min="$(clamp_int "$(read_setting suspendMinutes 0)" 0 480 0)"
fi
lock_on_sleep="$(read_setting lockOnSleep true)"
[[ "$lock_on_sleep" == "true" || "$lock_on_sleep" == "false" ]] || lock_on_sleep=true
}
@@ -162,7 +170,15 @@ case "${1:-apply}" in
install)
generate
install_dropin
# Restart only a daemon that is already running. `restart` on an
# inactive unit STARTS it, and this verb now also runs from
# change-settings during ./install -- possibly inside a GNOME
# session, where starting hypridle would fight GNOME's own idle
# handling. A session that has not started hypridle yet picks the
# drop-in up at its next launch.
if systemctl --user is-active -q hypridle.service; then
systemctl --user restart hypridle.service
fi
;;
remove)
rm -f "$dropin"
@@ -120,6 +120,29 @@ Singleton {
}
}
// The "Warn at" threshold from the Power page. A quiet mention, not an
// interruption: Do Not Disturb may still silence it, and the bar glyph
// turns with it. For a long time this threshold changed only the glyph's
// color, which nobody watching their work ever saw -- the first actual
// message arrived at the critical level.
Connections {
target: Battery
function onLowChanged(): void {
if (!root.powerInitialized || !Battery.low || Battery.critical)
return;
StatusEvents.publish({
key: "battery-low",
glyph: "\u{F007A}",
title: "Battery low",
detail: Math.round(Battery.percent) + "% remaining",
tone: "warning",
priority: StatusEvents.importantPriority,
actionId: "open-settings",
actionData: "power"
});
}
}
// Running out is not ambient. Published at a priority Do Not Disturb does
// not silence, because the one notification you must not miss is the one
// saying the machine is about to stop.
+12 -19
View File
@@ -113,11 +113,9 @@ Singleton {
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
};
// Only `enabled` -- see the lock-screen note below for why the rule
// model carries nothing else, and drops old fields on the way through.
return { enabled: source.enabled !== false };
}
function appRule(appId: string): var {
@@ -133,9 +131,7 @@ Singleton {
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
enabled: patch.enabled === undefined ? current.enabled : patch.enabled === true
};
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
@@ -158,17 +154,14 @@ Singleton {
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: var): bool {
const rule = root.appRule(root.notificationAppId(notification));
return rule.enabled && rule.showOnLockScreen;
}
function shouldShowContentOnLockScreen(notification: var): bool {
const rule = root.appRule(root.notificationAppId(notification));
return rule.enabled && rule.showOnLockScreen && rule.showContentOnLockScreen;
}
// There are deliberately no lock-screen policy getters here, and no
// lock-screen switches on the Notifications page. The lock screen is
// hyprlock, which cannot render notifications, so two per-app privacy
// toggles shipped for a while that controlled nothing -- on the page a
// person checks precisely when they care. Stored rules may still carry
// showOnLockScreen fields from that era; nothing reads them. If a lock
// screen that can render notifications ever exists, the rule store and
// appRule() are the right place to hang its policy back onto.
// history grouped by app, in most-recent-app-first order — the shape
// NotificationList.qml renders directly.
+13
View File
@@ -95,3 +95,16 @@ if exists vicinae; then
log "Setting vicinae theme"
vicinae theme set tokyonight-moon >/dev/null 2>&1 || true
fi
# A machine with a battery gets managed idle timings from the start. The
# shipped hypridle.conf deliberately never suspends -- correct for a desktop,
# and exactly wrong for a laptop in a bag. panama-idle generates the
# power-source-aware config and points hypridle at it with a drop-in; it does
# not start hypridle here (the Hyprland session does that), so running it
# under GNOME during install is safe. A desktop skips this entirely and keeps
# the shipped file, as before.
if "${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-hw" battery 2>/dev/null; then
log "Battery detected: enabling managed idle timings (suspend on battery works out of the box)"
"${PANAMA_PATH:-$HOME/.local/share/Panama}/config/dot/quickshell/scripts/panama-idle" install \
|| log "Could not enable managed idle timings; the Power page can turn them on later"
fi
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Every compositor-owned preference must be read at config time, not only
# applied live.
#
# A schema entry with `hypr:` metadata gets two delivery paths by design
# (stated in services/SystemSettings.qml): hyprctl eval reaches the running
# compositor, and a prefs.get() in the Lua is what survives `hyprctl reload`
# and the moment before the shell starts. Twenty-one keys once shipped with
# only the live half -- the entire Mouse & Touchpad page quietly reverted on
# every reload, and the schema's touchpad natural-scroll default disagreed
# with Hyprland's, so the page misreported the hardware until the shell's
# startup replay ran.
#
# Two properties, then: every hypr-backed key has a prefs.get() somewhere in
# config/dot/hypr/, and the Lua fallback equals the schema default -- a
# different fallback is the same lie on a different day.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
python3 - "$repo_dir" <<'PY'
import re, sys, pathlib
repo = pathlib.Path(sys.argv[1])
schema = (repo / "config/dot/quickshell/config/PreferenceSchema.qml").read_text()
# Pair each key with its entry body (up to the next key) and keep the ones
# carrying hypr: metadata, along with their declared default.
keys = [(m.group(1), m.start()) for m in re.finditer(r'key: "([A-Za-z]+)"', schema)]
failures = []
backed = {}
for i, (key, pos) in enumerate(keys):
end = keys[i + 1][1] if i + 1 < len(keys) else len(schema)
body = schema[pos:end]
if "hypr: {" not in body:
continue
m = re.search(r'def: (\([^)]*\)|"[^"]*"|[-\d.]+|true|false)', body)
if not m:
failures.append(f"{key}: hypr-backed but its default could not be read")
continue
backed[key] = m.group(1).strip('"')
lua = ""
for path in (repo / "config/dot/hypr").glob("*.lua"):
lua += path.read_text()
def normalize(value):
# A bool schema key can back an int compositor option (autoHdr -> render:
# cm_auto_hdr), read through prefs.getInt; true/1 and false/0 are the
# same declared default on the two sides.
v = {"true": "1", "false": "0"}.get(str(value), str(value))
try:
return repr(float(v))
except ValueError:
return v
for key, default in sorted(backed.items()):
reads = re.findall(
r'prefs\.get(?:Int)?\(\s*"' + key + r'"\s*,\s*("[^"]*"|[-\d.]+|true|false)\s*\)', lua)
if not reads:
failures.append(f"{key}: no prefs.get() in config/dot/hypr -- reverts on hyprctl reload")
continue
for fallback in reads:
if normalize(fallback.strip('"')) != normalize(default):
failures.append(
f"{key}: Lua fallback {fallback} disagrees with schema default {default!r}")
if failures:
print(f"hypr prefs contract: {len(failures)} finding(s)")
for failure in failures:
print(f" - {failure}")
sys.exit(1)
print(f"hypr prefs contract: ok ({len(backed)} compositor-owned keys read at config time)")
PY
+15 -5
View File
@@ -134,13 +134,23 @@ grep -q 'timeout = 1800' "$generated" || fail 'on wall power, the AC blank timin
grep -q 'timeout = 2700' "$generated" || fail 'on wall power, the AC lock timing was not used'
grep -q 'systemctl suspend' "$generated" && fail 'on wall power, a battery-only suspend listener was written'
# A battery key that was never written falls back to its AC counterpart.
# A battery key that was never written falls back to ITS OWN schema default,
# not to the AC value. This section once pinned the opposite -- protecting a
# deliberate AC setting from being "overridden" by unplugging -- but the
# Power page's battery card shows the schema defaults for unwritten battery
# keys, so the AC fallback made the generator disagree with what the screen
# said: a fresh laptop displayed "suspend at 20 minutes on battery" and
# generated no suspend listener at all. Whatever the sliders show is what
# must be generated; a person who wants battery to match AC sets it so, on
# the card that has said the real values all along.
stub_hw 0 1
run_powered '{"screenBlankMinutes":30,"lockMinutes":45}'
grep -q 'timeout = 1800' "$generated" \
|| fail 'an unset battery blank did not fall back to the AC value, so unplugging would override a deliberate setting'
grep -q 'timeout = 2700' "$generated" \
|| fail 'an unset battery lock did not fall back to the AC value'
grep -q 'timeout = 120' "$generated" \
|| fail 'an unset battery blank did not use the schema default the Power page displays'
grep -q 'timeout = 300' "$generated" \
|| fail 'an unset battery lock did not use the schema default the Power page displays'
grep -q 'systemctl suspend' "$generated" \
|| fail 'an unset battery suspend generated no suspend listener -- the discharged-in-a-bag bug, back again'
# No battery at all: the battery keys are never consulted, even when present.
stub_hw 1 0
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# The idle generator's fallbacks must equal the schema's defaults.
#
# The Power page shows the schema default for an unwritten key; panama-idle
# generates from its own fallback for the same key. When the two disagreed
# (schema said suspend on battery at 20 minutes, the generator fell back to
# the AC value, never), a fresh laptop displayed one behavior and shipped
# another. The UI and the generator read the same file; this pins them to the
# same defaults too.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
python3 - "$repo_dir" <<'PY'
import re, sys, pathlib
repo = pathlib.Path(sys.argv[1])
schema = (repo / "config/dot/quickshell/config/PreferenceSchema.qml").read_text()
idle = (repo / "config/dot/quickshell/scripts/panama-idle").read_text()
def schema_default(key):
m = re.search(r'key: "' + key + r'".*?def: ([-\d.]+|true|false)', schema, re.S)
return m.group(1) if m else None
failures = []
# Every read_setting with a literal fallback, including the nested clamp_int
# default that guards a malformed value.
for key, fallback in re.findall(r'read_setting\s+"?([A-Za-z]+)"?\s+(?:\\\n\s*)?"?([\w.]+)"?', idle):
expected = schema_default(key)
if expected is None:
failures.append(f"{key}: generator reads a key the schema does not declare")
elif fallback != expected:
failures.append(f"{key}: generator falls back to {fallback}, schema default is {expected}")
if failures:
print(f"idle defaults contract: {len(failures)} finding(s)")
for failure in failures:
print(f" - {failure}")
sys.exit(1)
print("idle defaults contract: ok (generator fallbacks match the schema)")
PY
@@ -76,12 +76,6 @@ ShellRoot {
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,
@@ -95,10 +89,6 @@ ShellRoot {
history: Notifs.history.length,
popups: Notifs.popups.length,
unread: Notifs.unreadCount
},
privacy: {
visible: Notifs.shouldShowOnLockScreen(privateNotification),
content: Notifs.shouldShowContentOnLockScreen(privateNotification)
}
});
}
@@ -107,11 +97,7 @@ ShellRoot {
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
});
Notifs.setAppRule("org.persist.App.desktop", { enabled: false });
return JSON.stringify(DesktopPreferences.get("notificationAppRules"));
}
@@ -56,14 +56,17 @@ for (const fixture of identityFixtures) {
}
const defaultRule = normalizedAppRule({});
if (JSON.stringify(defaultRule) !== JSON.stringify({ enabled: true, showOnLockScreen: true, showContentOnLockScreen: true }))
if (JSON.stringify(defaultRule) !== JSON.stringify({ enabled: true }))
fail(`missing rule fields did not default safely: ${JSON.stringify(defaultRule)}`);
// 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, showOnLockScreen: false, showContentOnLockScreen: false }))
fail(`explicit rule was not preserved: ${JSON.stringify(explicitRule)}`);
if (JSON.stringify(explicitRule) !== JSON.stringify({ enabled: false }))
fail(`stale lock-screen fields were not dropped: ${JSON.stringify(explicitRule)}`);
for (const required of ["rememberApplication", "appRule", "setAppRule", "handleNotification", "shouldShowOnLockScreen", "shouldShowContentOnLockScreen"]) {
for (const required of ["rememberApplication", "appRule", "setAppRule", "handleNotification"]) {
functionBody(required);
}
@@ -109,14 +112,20 @@ for (const required of [
// that the call is still in a binding, not that it is spelled one way.
"Notifs.appRule(",
".enabled",
"showOnLockScreen",
"showContentOnLockScreen",
"Notifs.setAppRule"
]) {
if (!page.includes(required))
fail(`settings page is missing ${required}`);
}
// 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))
fail(`settings page offers ${forbidden}, which nothing in a hyprlock session reads`);
}
console.log("notification application rules contract: PASS");
'
@@ -184,15 +193,10 @@ 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
}
"org.signal.Signal.desktop": { enabled: 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 } and
.fallback == {
id: "Fallback Terminal",
application: { id: "Fallback Terminal", name: "Fallback Terminal" }
@@ -201,11 +205,7 @@ jq -e '
persisted="$(qs_for_test ipc call notification-app-rules-test persist)"
jq -e '. == {
"org.persist.App.desktop": {
enabled: false,
showOnLockScreen: true,
showContentOnLockScreen: false
}
"org.persist.App.desktop": { enabled: false }
}' <<<"$persisted" >/dev/null || fail "runtime persistence fixture wrote the wrong shape: $persisted"
settings_file="$config_home/panama/settings.json"