Panama had grown into three configuration surfaces that only agreed because they had been typed to agree: looks.lua hardcoded values, DesktopPreferences independently defaulted the same values, and SystemSettings replayed them at startup. Nothing kept them in sync, and the Lua side read no shared state at all. This lands the first three stages of docs/superpowers/plans/2026-08-17-panama-cohesion.md. Fix silently failing Hyprland writes. On a Lua-configured Hyprland, hyprctl keyword refuses the write, prints the refusal to stdout, and still exits 0, so the HDR, VRR, and direct-scanout toggles persisted their value and reported success while the compositor never changed. Writes now go through hyprctl eval, which has the same hazard on syntax and runtime errors, so success is defined as reading the value back and finding it equal. The existing contract passed throughout the outage because it re-applied the values already in place; the new one flips each value to something it does not hold. Derive preferences from a schema. Every setting used to be restated four times -- a property alias, a JSON adapter property, a change handler, and a line in reset -- where omitting any one failed silently. PreferenceSchema.qml is now the single source, and persistence, validation, reset, and the Hyprland mapping all derive from it. Unknown keys on disk survive a write so a rollback does not discard a newer build's settings, and a corrupt file falls back to shipped defaults. The store moved to ~/.config/panama/settings.json, migrating from the old state directory without deleting it. Share that file with Hyprland. prefs.lua reads it at config time with every shipped literal kept as the fallback, so the config still stands alone. The Lua is the default, the JSON is the truth, and Settings is the editor. The compositor-adjustable surface goes from 3 keys to 23. Also fixes two test-hygiene bugs found by running the suite end to end for the first time: settings-pages-contract could see the window settings-window-contract leaves behind, and the new write contract was persisting its deliberately-wrong values into the user's real store. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
129 lines
6.5 KiB
Bash
Executable File
129 lines
6.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# config/dot/hypr/prefs.lua is read at config time by looks.lua, input.lua, and
|
|
# monitors.lua. It is therefore the one piece of Panama that can cost the user a
|
|
# working compositor rather than merely a working feature.
|
|
#
|
|
# This contract pins the only behaviour that matters: whatever is in the
|
|
# settings file -- including nothing, garbage, or values of the wrong type --
|
|
# the config still parses, and every setting either takes the stored value or
|
|
# falls back to the value shipped in the Lua.
|
|
|
|
set -euo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
hypr_dir="$repo_dir/config/dot/hypr"
|
|
work="$(mktemp -d /tmp/panama-prefs-contract.XXXXXX)"
|
|
|
|
fail() {
|
|
printf 'prefs fallback contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
cleanup() {
|
|
rm -rf "$work"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
# Exercise prefs.lua directly rather than through a full compositor launch: the
|
|
# question is what the module returns, and Hyprland's own parse is covered by
|
|
# the --verify-config case at the end.
|
|
probe() {
|
|
local settings="$1"
|
|
mkdir -p "$work/config/panama"
|
|
if [[ "$settings" == "__absent__" ]]; then
|
|
rm -f "$work/config/panama/settings.json"
|
|
else
|
|
printf '%s' "$settings" >"$work/config/panama/settings.json"
|
|
fi
|
|
|
|
XDG_CONFIG_HOME="$work/config" lua -e "
|
|
package.path = '$hypr_dir/?.lua;' .. package.path
|
|
local ok, prefs = pcall(require, 'prefs')
|
|
if not ok then
|
|
print('LOAD_ERROR ' .. tostring(prefs))
|
|
os.exit(0)
|
|
end
|
|
print(string.format(
|
|
'gapsOut=%s rounding=%s blur=%s layout=%s hdr=%s',
|
|
tostring(prefs.get('gapsOut', 10)),
|
|
tostring(prefs.get('windowRounding', 18)),
|
|
tostring(prefs.get('blurEnabled', true)),
|
|
tostring(prefs.get('keyboardLayout', 'us')),
|
|
tostring(prefs.getInt('autoHdr', 1))))
|
|
" 2>&1
|
|
}
|
|
|
|
shipped='gapsOut=10 rounding=18 blur=true layout=us hdr=1'
|
|
|
|
# ── No file at all: the normal first run ─────────────────────────────────────
|
|
result="$(probe '__absent__')"
|
|
[[ "$result" == "$shipped" ]] || fail "an absent settings file did not yield shipped defaults: $result"
|
|
|
|
# ── Empty file ───────────────────────────────────────────────────────────────
|
|
result="$(probe '')"
|
|
[[ "$result" == "$shipped" ]] || fail "an empty settings file did not yield shipped defaults: $result"
|
|
|
|
# ── Whitespace only ──────────────────────────────────────────────────────────
|
|
result="$(probe '
|
|
')"
|
|
[[ "$result" == "$shipped" ]] || fail "a whitespace-only file did not yield shipped defaults: $result"
|
|
|
|
# ── Truncated mid-object, the shape a crashed write leaves behind ────────────
|
|
result="$(probe '{ "gapsOut": 24, "windowRounding":')"
|
|
[[ "$result" == "$shipped" ]] || fail "a truncated file did not yield shipped defaults: $result"
|
|
|
|
# ── Not JSON at all ──────────────────────────────────────────────────────────
|
|
result="$(probe 'gaps_out = 24')"
|
|
[[ "$result" == "$shipped" ]] || fail "a non-JSON file did not yield shipped defaults: $result"
|
|
|
|
# ── A JSON array rather than an object ───────────────────────────────────────
|
|
result="$(probe '[1, 2, 3]')"
|
|
[[ "$result" == "$shipped" ]] || fail "a top-level array did not yield shipped defaults: $result"
|
|
|
|
# ── Wrong types: each bad value falls back on its own ────────────────────────
|
|
result="$(probe '{"gapsOut": "wide", "windowRounding": 24, "blurEnabled": 3, "keyboardLayout": 7}')"
|
|
[[ "$result" == 'gapsOut=10 rounding=24 blur=true layout=us hdr=1' ]] \
|
|
|| fail "wrong-typed values did not fall back per key: $result"
|
|
|
|
# ── Good values are actually used ────────────────────────────────────────────
|
|
result="$(probe '{"gapsOut": 24, "windowRounding": 6, "blurEnabled": false, "keyboardLayout": "us,de", "autoHdr": false}')"
|
|
[[ "$result" == 'gapsOut=24 rounding=6 blur=false layout=us,de hdr=0' ]] \
|
|
|| fail "stored values were not applied: $result"
|
|
|
|
# ── A boolean maps onto an integer option, matching SystemSettings.hyprValue ─
|
|
result="$(probe '{"autoHdr": true}')"
|
|
[[ "$result" == 'gapsOut=10 rounding=18 blur=true layout=us hdr=1' ]] \
|
|
|| fail "getInt did not convert a boolean: $result"
|
|
|
|
# ── Escapes and nesting do not break the reader ──────────────────────────────
|
|
result="$(probe '{"note": "a \"quoted\" value\nwith escapes", "nested": {"a": [1, 2, {"b": null}]}, "gapsOut": 12}')"
|
|
[[ "$result" == 'gapsOut=12 rounding=18 blur=true layout=us hdr=1' ]] \
|
|
|| fail "a file with escapes and nesting was not parsed: $result"
|
|
|
|
# ── The real config parses in every one of those states ─────────────────────
|
|
# This is the case that actually protects the desktop: prefs.lua returning
|
|
# defaults is only useful if Hyprland still accepts the config around it.
|
|
#
|
|
# Hyprland resolves its own config from $HOME/.config/hypr regardless of
|
|
# XDG_CONFIG_HOME, while prefs.lua honours XDG_CONFIG_HOME. That asymmetry is
|
|
# what makes this loop useful rather than vacuous: the *real* looks.lua and
|
|
# input.lua are parsed against a *fixture* settings file. The probe cases above
|
|
# already establish that prefs.lua reads the fixture and not the live file.
|
|
for settings in '__absent__' '' '{ "gapsOut": 24, "windowRounding":' 'gaps_out = 24' '{"gapsOut": "wide"}' \
|
|
'{"gapsOut": 24, "windowRounding": 6, "blurEnabled": false, "borderSize": 0, "keyboardLayout": "us,de"}'; do
|
|
if [[ "$settings" == "__absent__" ]]; then
|
|
rm -f "$work/config/panama/settings.json"
|
|
else
|
|
mkdir -p "$work/config/panama"
|
|
printf '%s' "$settings" >"$work/config/panama/settings.json"
|
|
fi
|
|
output="$(XDG_CONFIG_HOME="$work/config" Hyprland --verify-config 2>&1 || true)"
|
|
grep -q 'config ok' <<<"$output" \
|
|
|| fail "Hyprland rejected the config with settings=<$settings>: $(tail -5 <<<"$output")"
|
|
done
|
|
|
|
trap - EXIT
|
|
cleanup
|
|
printf 'prefs fallback contract: PASS\n'
|