Make Panama settings one shared source of truth
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
This commit is contained in:
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The preference store is derived from config/PreferenceSchema.qml rather than
|
||||
# restating each key. This contract pins the properties that derivation is
|
||||
# supposed to buy, so that a future change cannot quietly reintroduce the
|
||||
# hand-maintained variant:
|
||||
#
|
||||
# * every schema key round-trips through disk
|
||||
# * out-of-range numbers are clamped, not stored raw and not rejected
|
||||
# * unknown keys in the file are preserved across a write
|
||||
# * set() refuses a key that is not in the schema
|
||||
# * reset() restores *every* schema default with no hand-written list
|
||||
# * a corrupt file yields shipped defaults rather than a broken shell
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
harness="$repo_dir/config/dot/quickshell/preference-schema-harness.qml"
|
||||
state_home="$(mktemp -d /tmp/panama-schema-state.XXXXXX)"
|
||||
config_home="$(mktemp -d /tmp/panama-schema-config.XXXXXX)"
|
||||
store="$config_home/panama/settings.json"
|
||||
|
||||
fail() {
|
||||
printf 'preference schema contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
qs_for_harness() {
|
||||
XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
qs_for_harness kill >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
start_harness() {
|
||||
XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
if qs_for_harness ipc show 2>/dev/null | rg -q '^target preference-schema-test$'; then
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
fail 'test IPC target did not start'
|
||||
}
|
||||
|
||||
stop_harness() {
|
||||
qs_for_harness kill >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 40); do
|
||||
# A bare `return` would propagate the failed `ipc show` status, which
|
||||
# under `set -e` ends the whole contract instead of the function.
|
||||
qs_for_harness ipc show >/dev/null 2>&1 || return 0
|
||||
sleep 0.1
|
||||
done
|
||||
fail 'test shell did not stop cleanly'
|
||||
}
|
||||
|
||||
wait_for_store() {
|
||||
for _ in $(seq 1 40); do
|
||||
[[ -f "$store" ]] && jq -e . "$store" >/dev/null 2>&1 && return
|
||||
sleep 0.1
|
||||
done
|
||||
fail 'preferences file was not written'
|
||||
}
|
||||
|
||||
# ── A fresh store reports schema defaults ────────────────────────────────────
|
||||
start_harness
|
||||
key_count="$(qs_for_harness ipc call preference-schema-test keyCount)"
|
||||
[[ "$key_count" -gt 0 ]] || fail 'schema is empty'
|
||||
|
||||
initial="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)"
|
||||
defaults="$(qs_for_harness ipc call preference-schema-test defaults | jq -cS .)"
|
||||
[[ "$initial" == "$defaults" ]] || fail "a fresh store did not report schema defaults: $initial"
|
||||
|
||||
# ── Out-of-range numbers are clamped, not stored raw ─────────────────────────
|
||||
qs_for_harness ipc call preference-schema-test applyJson \
|
||||
'{"dockHideDelayMs": 99999, "nightLightTemperature": 100, "focusDurationMinutes": 45}' >/dev/null
|
||||
clamped="$(qs_for_harness ipc call preference-schema-test dump)"
|
||||
[[ "$(jq -r .dockHideDelayMs <<<"$clamped")" == "2000" ]] \
|
||||
|| fail "an above-range value was not clamped to the schema maximum: $(jq -r .dockHideDelayMs <<<"$clamped")"
|
||||
[[ "$(jq -r .nightLightTemperature <<<"$clamped")" == "2000" ]] \
|
||||
|| fail "a below-range value was not clamped to the schema minimum: $(jq -r .nightLightTemperature <<<"$clamped")"
|
||||
|
||||
# ── An unknown key is refused rather than silently accepted ──────────────────
|
||||
verdict="$(qs_for_harness ipc call preference-schema-test applyJson '{"__not_a_setting__": 1}')"
|
||||
[[ "$(jq -r .__not_a_setting__ <<<"$verdict")" == "false" ]] || fail 'set() accepted a key outside the schema'
|
||||
qs_for_harness ipc call preference-schema-test dump | jq -e 'has("__not_a_setting__") | not' >/dev/null \
|
||||
|| fail 'a key outside the schema entered the store'
|
||||
|
||||
# ── An out-of-range enum value is refused ────────────────────────────────────
|
||||
verdict="$(qs_for_harness ipc call preference-schema-test applyJson '{"vrrPolicy": 7}')"
|
||||
[[ "$(jq -r .vrrPolicy <<<"$verdict")" == "false" ]] || fail 'set() accepted an enum value outside its options'
|
||||
|
||||
# ── Every schema key round-trips across a restart ────────────────────────────
|
||||
qs_for_harness ipc call preference-schema-test applyJson \
|
||||
'{"use24Hour": true, "showSeconds": false, "showWeekday": false, "showCpu": false,
|
||||
"showMemory": false, "showGpu": false, "dockAutohide": false, "dockRevealDelayMs": 75,
|
||||
"dockHideDelayMs": 400, "focusDurationMinutes": 25, "autoHdr": false, "vrrPolicy": 0,
|
||||
"directScanoutPolicy": 0, "nightLightEnabled": true, "nightLightAutomatic": true,
|
||||
"nightLightTemperature": 4100, "lastPage": "desktop"}' >/dev/null
|
||||
wait_for_store
|
||||
before="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)"
|
||||
[[ "$before" != "$defaults" ]] || fail 'the fixture did not change anything'
|
||||
|
||||
stop_harness
|
||||
start_harness
|
||||
after=""
|
||||
for _ in $(seq 1 40); do
|
||||
after="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)"
|
||||
[[ "$after" == "$before" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[[ "$after" == "$before" ]] || fail "values did not survive a restart: $after"
|
||||
|
||||
# ── A key this build does not know is carried through, not dropped ───────────
|
||||
stop_harness
|
||||
jq '. + {"__future_setting__": "keep me"}' "$store" >"$store.tmp" && mv "$store.tmp" "$store"
|
||||
start_harness
|
||||
qs_for_harness ipc call preference-schema-test applyJson '{"dockHideDelayMs": 500}' >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
jq -e '.__future_setting__ == "keep me" and .dockHideDelayMs == 500' "$store" >/dev/null 2>&1 && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.__future_setting__ == "keep me"' "$store" >/dev/null \
|
||||
|| fail 'a setting from a newer build was dropped on write'
|
||||
|
||||
# ── reset() restores every schema key, with no hand-maintained list ──────────
|
||||
qs_for_harness ipc call preference-schema-test reset >/dev/null
|
||||
restored=""
|
||||
for _ in $(seq 1 40); do
|
||||
restored="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)"
|
||||
[[ "$restored" == "$defaults" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[[ "$restored" == "$defaults" ]] || fail "reset did not restore every schema default: $restored"
|
||||
jq -e '.__future_setting__ == "keep me"' "$store" >/dev/null \
|
||||
|| fail 'reset discarded a setting it does not own'
|
||||
|
||||
# ── A corrupt file degrades to defaults instead of breaking the shell ────────
|
||||
stop_harness
|
||||
printf '{ this is not json' >"$store"
|
||||
start_harness
|
||||
corrupt="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)"
|
||||
[[ "$corrupt" == "$defaults" ]] || fail "a corrupt store did not fall back to defaults: $corrupt"
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'preference schema contract: PASS\n'
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Panama Settings must actually change the compositor, not merely believe it did.
|
||||
#
|
||||
# The pre-existing settings-system-contract.sh applies the values the compositor
|
||||
# already holds and asserts they are unchanged, so it passes whether the write
|
||||
# works or does nothing at all. That is how `hyprctl keyword` silently failing --
|
||||
# it prints "keyword can't work with non-legacy parsers" to stdout and exits 0 --
|
||||
# went unnoticed on this Lua-configured Hyprland.
|
||||
#
|
||||
# This contract flips each policy to a value it does not currently hold, reads it
|
||||
# back from the compositor, and restores the original. A write path that no-ops
|
||||
# cannot pass it.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml"
|
||||
|
||||
# A verified write commits to preferences, and preferences live at
|
||||
# $XDG_CONFIG_HOME/panama/settings.json. Without an isolated config home this
|
||||
# contract would persist its deliberately-wrong test values into the user's real
|
||||
# store, where the next `hyprctl reload` would faithfully apply them. The
|
||||
# compositor is still the live one -- that is the point of the contract -- and
|
||||
# the EXIT trap restores it.
|
||||
config_home="$(mktemp -d /tmp/panama-write-config.XXXXXX)"
|
||||
|
||||
fail() {
|
||||
printf 'settings hyprland write contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
qs_for_harness() {
|
||||
XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
read_option() {
|
||||
hyprctl -j getoption "$1" | jq -r .int
|
||||
}
|
||||
|
||||
# Every value this contract touches is captured up front, before anything is
|
||||
# changed. Capturing later risks recording a value an earlier failed run left
|
||||
# behind and then "restoring" the daily-driver desktop to it.
|
||||
original_auto_hdr="$(read_option render:cm_auto_hdr)"
|
||||
original_vrr="$(read_option misc:vrr)"
|
||||
original_direct="$(read_option render:direct_scanout)"
|
||||
original_rounding="$(read_option decoration:rounding)"
|
||||
original_gaps="$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')"
|
||||
original_blur="$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)"
|
||||
original_opacity="$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float)"
|
||||
original_layout="$(hyprctl -j getoption input:kb_layout | jq -r .str)"
|
||||
|
||||
restore() {
|
||||
# Restore through hyprctl rather than the harness: if the harness write path
|
||||
# is the thing that is broken, the daily-driver desktop must still come back.
|
||||
# Unconditional and idempotent, so it is safe on both the pass and fail path.
|
||||
hyprctl eval "hl.config({
|
||||
render = { cm_auto_hdr = $original_auto_hdr, direct_scanout = $original_direct },
|
||||
misc = { vrr = $original_vrr },
|
||||
general = { gaps_out = $original_gaps },
|
||||
input = { kb_layout = \"$original_layout\" },
|
||||
decoration = {
|
||||
rounding = $original_rounding,
|
||||
inactive_opacity = $original_opacity,
|
||||
blur = { enabled = $original_blur }
|
||||
}
|
||||
})" >/dev/null 2>&1 || true
|
||||
qs_for_harness kill >/dev/null 2>&1 || true
|
||||
rm -rf "$config_home"
|
||||
}
|
||||
trap restore EXIT
|
||||
|
||||
# Pick a target each policy does not currently hold, staying inside the values
|
||||
# SystemSettings allow-lists (VRR 0|3, direct scanout 0|2).
|
||||
target_auto_hdr=$([[ "$original_auto_hdr" == 1 ]] && printf false || printf true)
|
||||
target_auto_hdr_int=$([[ "$target_auto_hdr" == true ]] && printf 1 || printf 0)
|
||||
target_vrr=$([[ "$original_vrr" == 3 ]] && printf 0 || printf 3)
|
||||
target_direct=$([[ "$original_direct" == 2 ]] && printf 0 || printf 2)
|
||||
|
||||
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$'; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' || fail 'test IPC target did not start'
|
||||
|
||||
# ── The write must reach the compositor ──────────────────────────────────────
|
||||
qs_for_harness ipc call settings-system-test apply "$target_auto_hdr" "$target_vrr" "$target_direct" >/dev/null
|
||||
|
||||
applied=false
|
||||
for _ in $(seq 1 40); do
|
||||
if [[ "$(read_option render:cm_auto_hdr)" == "$target_auto_hdr_int" \
|
||||
&& "$(read_option misc:vrr)" == "$target_vrr" \
|
||||
&& "$(read_option render:direct_scanout)" == "$target_direct" ]]; then
|
||||
applied=true
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
[[ "$applied" == true ]] || fail "policy write did not reach the compositor: \
|
||||
cm_auto_hdr=$(read_option render:cm_auto_hdr) (want $target_auto_hdr_int), \
|
||||
vrr=$(read_option misc:vrr) (want $target_vrr), \
|
||||
direct_scanout=$(read_option render:direct_scanout) (want $target_direct)"
|
||||
|
||||
# ── A successful write must not report an error ──────────────────────────────
|
||||
last_error="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)"
|
||||
[[ -z "$last_error" ]] || fail "a successful write reported an error: $last_error"
|
||||
|
||||
# ── A rejected value must be refused, not silently accepted ──────────────────
|
||||
qs_for_harness ipc call settings-system-test apply "$target_auto_hdr" 7 "$target_direct" >/dev/null
|
||||
sleep 0.3
|
||||
[[ "$(read_option misc:vrr)" == "$target_vrr" ]] || fail 'an out-of-allow-list VRR value reached the compositor'
|
||||
[[ -n "$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)" ]] \
|
||||
|| fail 'a rejected VRR value did not surface an error'
|
||||
|
||||
# ── Every getoption answer shape must be handled, not just integers ──────────
|
||||
# The compositor reports each option in a different JSON field depending on its
|
||||
# type, and gaps come back as a four-value box. A verifier that only understood
|
||||
# "int" would report every other type as rejected.
|
||||
qs_for_harness ipc call settings-system-test applyJson \
|
||||
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
|
||||
|
||||
typed=false
|
||||
for _ in $(seq 1 40); do
|
||||
if [[ "$(read_option decoration:rounding)" == "7" \
|
||||
&& "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \
|
||||
&& "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \
|
||||
&& "$(hyprctl -j getoption decoration:inactive_opacity | jq -r '.float | (.*100|round)')" == "85" ]]; then
|
||||
typed=true
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if [[ "$typed" != true ]]; then
|
||||
fail "a typed batch did not reach the compositor: rounding=$(read_option decoration:rounding), \
|
||||
gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \
|
||||
blur=$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool), \
|
||||
opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float)"
|
||||
fi
|
||||
|
||||
# Verification must recognise those shapes as success, not report them rejected.
|
||||
last_error="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)"
|
||||
if [[ -n "$last_error" ]]; then
|
||||
fail "a verified typed write was reported as failed: $last_error"
|
||||
fi
|
||||
|
||||
# ── A string that violates its schema pattern must never reach hl.config ──────
|
||||
[[ "$(qs_for_harness ipc call settings-system-test applyJson '{"keyboardLayout": "us\"; os.execute(\"touch /tmp/panama-pwned\")--"}')" == "false" ]] \
|
||||
|| fail 'a keyboard layout violating the schema pattern was accepted'
|
||||
[[ ! -e /tmp/panama-pwned ]] || fail 'a settings value was executed as Lua'
|
||||
[[ "$(hyprctl -j getoption input:kb_layout | jq -r .str)" == "$original_layout" ]] \
|
||||
|| fail 'the keyboard layout changed despite a rejected value'
|
||||
|
||||
# ── Restoring through the real path must also work ───────────────────────────
|
||||
qs_for_harness ipc call settings-system-test apply \
|
||||
"$([[ "$original_auto_hdr" == 1 ]] && printf true || printf false)" \
|
||||
"$original_vrr" "$original_direct" >/dev/null
|
||||
|
||||
restored=false
|
||||
for _ in $(seq 1 40); do
|
||||
if [[ "$(read_option render:cm_auto_hdr)" == "$original_auto_hdr" \
|
||||
&& "$(read_option misc:vrr)" == "$original_vrr" \
|
||||
&& "$(read_option render:direct_scanout)" == "$original_direct" ]]; then
|
||||
restored=true
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
[[ "$restored" == true ]] || fail 'the original policy values could not be restored through SystemSettings'
|
||||
|
||||
trap - EXIT
|
||||
restore
|
||||
printf 'settings hyprland write contract: PASS\n'
|
||||
@@ -12,6 +12,19 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Start from a clean slate. Closing the Settings window is asynchronous: the
|
||||
# shell reports it closed as soon as it drops its own state, while the toplevel
|
||||
# survives until the compositor destroys it. Without this wait, running straight
|
||||
# after settings-window-contract sees the outgoing window and reads it as a
|
||||
# duplicate.
|
||||
qs ipc call settings close >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 40); do
|
||||
hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 0' >/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 0' >/dev/null \
|
||||
|| fail 'a Settings window was still open when the contract started'
|
||||
|
||||
pages=(home appearance displays connectivity desktop sound notifications screen-intelligence shortcuts services about)
|
||||
for page in "${pages[@]}"; do
|
||||
qs ipc call settings page "$page" >/dev/null
|
||||
|
||||
@@ -5,6 +5,7 @@ set -euo pipefail
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
harness="$repo_dir/config/dot/quickshell/settings-preferences-harness.qml"
|
||||
state_home="$(mktemp -d /tmp/panama-settings-state.XXXXXX)"
|
||||
config_home="$(mktemp -d /tmp/panama-settings-config.XXXXXX)"
|
||||
|
||||
fail() {
|
||||
printf 'settings preferences contract: %s\n' "$1" >&2
|
||||
@@ -12,7 +13,7 @@ fail() {
|
||||
}
|
||||
|
||||
qs_for_harness() {
|
||||
XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
|
||||
XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
@@ -21,7 +22,7 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
start_harness() {
|
||||
XDG_STATE_HOME="$state_home" qs -p "$harness" --daemonize >/dev/null
|
||||
XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-pref-test$'; then
|
||||
return
|
||||
@@ -51,7 +52,7 @@ before="$(qs_for_harness ipc call settings-pref-test status | jq -c 'del(.stateD
|
||||
|
||||
state_file=""
|
||||
for _ in $(seq 1 40); do
|
||||
state_file="$(find "$state_home" -name panama-settings.json -print -quit)"
|
||||
state_file="$(find "$config_home" -path '*/panama/settings.json' -print -quit)"
|
||||
if [[ -n "$state_file" ]] && jq -e '.lastPage == "desktop" and .focusDurationMinutes == 70' "$state_file" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user