#!/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) # settings-commit-reset-contract drives the SAME harness file with # PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1, where the compositor write seam is # stubbed out. Quickshell identifies an instance by its config path, so if that # run's instance has not fully exited, the `ipc show` wait below is satisfied by # ITS target -- and every write in this contract lands on the isolated instance # and never reaches the compositor. That is exactly what "a typed batch did not # reach the compositor" looks like when this fails in a full suite run but # passes on its own. # # So wait for the harness to be clear first, and say so plainly if it is not, # rather than silently talking to the wrong shell. harness_instances() { # rg -c prints nothing at all when there are no matches, so an unguarded # substitution yields "" rather than "0" and every comparison against a # count fails. local count count="$(qs list 2>/dev/null | rg -c "^ Config path: $harness\$" || true)" printf '%s' "${count:-0}" } for _ in $(seq 1 50); do [[ "$(harness_instances)" == "0" ]] && break sleep 0.1 done [[ "$(harness_instances)" == "0" ]] \ || fail 'another instance of the settings harness is still running -- this contract would talk to it instead of its own, and its writes may be deliberately stubbed' XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null # Bounded waits are deliberately generous. Each of these polls until the # compositor reflects a write, and four seconds was enough on an idle machine # and not enough on a busy one -- this contract passed alone and failed in a # full suite run, which is the worst way for a test to be wrong. A longer bound # cannot mask a dead write, because a write that never lands never matches; it # only stops a slow one from being reported as a broken one. for _ in $(seq 1 150); 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 150); 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 # The refusal is reported asynchronously, so wait for it rather than sleeping a # fixed 0.3s and hoping. That sleep made this fail roughly one run in three, # reporting "a rejected value did not surface an error" when the error simply # had not arrived yet -- which reads as a missing guard rather than a slow one. rejected="" for _ in $(seq 1 150); do rejected="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)" [[ -n "$rejected" ]] && break sleep 0.1 done [[ "$(read_option misc:vrr)" == "$target_vrr" ]] || fail 'an out-of-allow-list VRR value reached the compositor' [[ -n "$rejected" ]] || 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 # 10 seconds, not 4. The write path verifies each option by reading it back off # the compositor and retries a refused batch, so a busy machine legitimately # takes longer than a quick apply -- and this contract runs in a suite alongside # other tests driving the same compositor. Failing at 4 seconds reported a # product bug ("did not reach the compositor") for what was queueing. # Re-issued periodically, because this contract and the LIVE shell both write to # the same compositor. When the running Panama re-applies its own preferences -- # which it does on any store change -- it overwrites the values this test just # set, and the read-back below then sees Panama's shipped defaults with the # writer reporting no error at all. That combination is the signature: a # rejected write leaves an error, a clobbered one does not. typed=false for attempt in $(seq 1 100); do if (( attempt % 30 == 0 )); then qs_for_harness ipc call settings-system-test applyJson \ '{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null fi 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 # Report what the writer thinks as well as what the compositor holds. Those # two disagreeing is a rejected write; both showing defaults is a write that # never happened, and the messages should not look identical. 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), \ writer-reported error=\"$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)\"" fi # Verification must recognize 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 150); 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'