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:
Executable
+76
@@ -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
|
||||
@@ -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
|
||||
|
||||
Executable
+43
@@ -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
|
||||
Reference in New Issue
Block a user