#!/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 behavior 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 honors 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'