#!/usr/bin/env bash # Per-application window rules, written by the user. # # This is settings.json describing compositor behaviour, which is the same # shape of risk custom shortcuts carry and is handled the same way: the stored # entry is data -- a class, some booleans, two numbers -- and hypr/rules.lua is # the only thing that turns it into a rule. # # Three properties, and every one of them was a real way to lose the desktop: # # 1. The class is matched LITERALLY. Hyprland matches with RE2, so a class # typed into a text field is a regular expression: "org.gnome.Files" would # also match "orgxgnomexFiles", and a half-typed "(" is a pattern error # rather than a rule that matches nothing. # 2. An invalid entry is skipped WHOLE. A rule that half-applies -- the size # dropped, the float kept -- is harder to understand than one that is not # there. # 3. The shell's own surfaces cannot be matched. A user floating or moving # Quickshell's windows from the Windows page is a person breaking their # desktop with a supported control. # # And one about ordering: user rules are ANONYMOUS. Hyprland evaluates every # named rule before every anonymous one, so naming a user rule would make it # lose to the shipped rules it is meant to override. # # The Lua is exercised with a stubbed `hl`, so the rules can be counted without # a compositor and without touching the running desktop. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" hypr_dir="$repo_dir/config/dot/hypr" rules="$hypr_dir/rules.lua" schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" findings=() note() { findings+=("$1"); } command -v lua >/dev/null 2>&1 || { printf 'window rules contract: lua is not installed\n' >&2 exit 1 } work="$(mktemp -d /tmp/panama-window-rules.XXXXXX)" trap 'rm -rf "$work"' EXIT mkdir -p "$work/config/panama" # Every window rule rules.lua emits for a given settings file, one per line, as # " ". Layer rules are swallowed: # this is about window rules, and a layer rule is not one. emit() { printf '%s' "$1" >"$work/config/panama/settings.json" XDG_CONFIG_HOME="$work/config" lua -e " package.path = '$hypr_dir/?.lua;' .. package.path hl = { layer_rule = function() end, window_rule = function(rule) local fields = {} for key, value in pairs(rule) do if key ~= 'match' and key ~= 'name' then if type(value) == 'table' then value = tostring(value[1]) .. 'x' .. tostring(value[2]) end fields[#fields + 1] = key .. '=' .. tostring(value) end end table.sort(fields) local class = (rule.match or {}).class print((rule.name or '-') .. ' ' .. tostring(class) .. ' ' .. table.concat(fields, ' ')) end, } dofile('$rules') " 2>/dev/null } count() { grep -c . <<<"$1" || true; } # ── The shipped baseline ───────────────────────────────────────────────────── # # Read rather than pinned as a number. Pinning one means every new shipped rule # fails this contract for no reason, and the property under test is the # DIFFERENCE the user's rules make, not how many rules Panama ships. shipped="$(emit '{"windowRules":[]}')" shipped_count="$(count "$shipped")" (( shipped_count > 0 )) || note 'rules.lua emitted no window rules at all, which cannot be right' absent="$(emit '{}')" (( "$(count "$absent")" == shipped_count )) \ || note 'with no windowRules key at all, the shipped rule count changes' # ── Five rules, two of which must not be emitted ────────────────────────────── # # * Calculator -- valid, and the one whose escaping is checked # * qs-dock -- the shell's own surface, refused # * Steam -- workspace 42, which does not exist; skipped whole # * foo(bar -- a class that is not a valid regex, and must not become one # * mygame -- valid, with every remaining field set USER_RULES='{"windowRules":[ {"class":"org.gnome.Calculator","label":"Calculator","float":true,"center":true,"size":[400,600]}, {"class":"qs-dock","label":"Dock","float":true}, {"class":"Steam","label":"Steam","workspace":42}, {"class":"foo(bar","label":"Odd","float":true,"pin":true}, {"class":"mygame","label":"Game","game":true,"noAnim":true,"noDim":true,"workspace":9} ]}' applied="$(emit "$USER_RULES")" applied_count="$(count "$applied")" (( applied_count == shipped_count + 3 )) \ || note "five user rules with two invalid emitted $(( applied_count - shipped_count )) rules, not 3" user_lines="$(comm -13 <(sort <<<"$shipped") <(sort <<<"$applied"))" # 1. The class is escaped and anchored, so it matches itself and nothing else. grep -qF '^org\.gnome\.Calculator$' <<<"$user_lines" \ || note 'the class is not regex-escaped, so a dotted application id matches classes the user did not name' grep -qF '^foo\(bar$' <<<"$user_lines" \ || note 'a class containing a regex metacharacter is not escaped, so it is a pattern rather than a name' # 2. Invalid entries are gone, and gone whole. grep -q 'qs-dock' <<<"$user_lines" \ && note 'a rule matching the shell\047s own surfaces was emitted' grep -q 'Steam' <<<"$user_lines" \ && note 'a rule with an out-of-range workspace was emitted' grep -qE 'Steam.*float' <<<"$user_lines" \ && note 'an invalid rule was emitted with its bad field dropped rather than skipped whole' # 3. The valid ones carry what they were given, under Hyprland's own names. grep -qE '\^org\\\.gnome\\\.Calculator\$.*center=true.*float=true.*size=400x600' <<<"$user_lines" \ || note 'the float/center/size rule did not survive translation: '"$(grep Calculator <<<"$user_lines")" grep -qE '\^mygame\$.*content=game.*no_anim=true.*no_dim=true.*workspace=9' <<<"$user_lines" \ || note 'the game rule did not survive translation: '"$(grep mygame <<<"$user_lines")" grep -qE '\^foo\\\(bar\$.*pin=true' <<<"$user_lines" \ || note 'pin did not survive translation' # 4. Anonymous, and after the shipped rules. Named would outrank every # anonymous shipped rule; earlier would let a shipped rule win the last-match # tiebreak against the user's own. while IFS= read -r line; do [[ -n "$line" ]] || continue [[ "${line%% *}" == "-" ]] \ || note "a user rule was emitted with the name '${line%% *}', which makes it outrank the shipped rules" done <<<"$user_lines" first_user="$(grep -n 'mygame' <<<"$applied" | head -1 | cut -d: -f1)" last_shipped="$(grep -c . <<<"$shipped")" [[ -n "$first_user" && "$first_user" -gt "$last_shipped" ]] \ || note 'user rules are not emitted after every shipped rule' # ── Sizes and workspaces have bounds ───────────────────────────────────────── # # Not decoration: a 4-pixel window is unreachable with the pointer, and a # workspace number outside what the keybinds reach strands a window somewhere # there is no shortcut to. for bad in \ '{"class":"tiny","size":[4,4]}' \ '{"class":"huge","size":[99999,99999]}' \ '{"class":"half","size":[400]}' \ '{"class":"zero","workspace":0}' \ '{"class":"","float":true}' \ '{"class":"quickshell","float":true}' \ '{"class":"QS-Dock","float":true}'; do out="$(emit "{\"windowRules\":[$bad]}")" (( "$(count "$out")" == shipped_count )) \ || note "an invalid rule was emitted anyway: $bad" done # A class of exactly the cap is fine; one past it is not. ok_class="$(printf 'a%.0s' $(seq 1 128))" long_class="$(printf 'a%.0s' $(seq 1 129))" (( "$(count "$(emit "{\"windowRules\":[{\"class\":\"$ok_class\"}]}")")" == shipped_count + 1 )) \ || note 'a class of exactly 128 characters is refused, so the cap is off by one' (( "$(count "$(emit "{\"windowRules\":[{\"class\":\"$long_class\"}]}")")" == shipped_count )) \ || note 'a class longer than 128 characters is emitted anyway' # ── Nothing malformed can cost the compositor ──────────────────────────────── # # The whole point of reading a hand-editable file at config time is that a bad # read costs the setting and never the desktop. A raise here aborts rules.lua, # and the desktop comes up with no window rules at all. for hostile in \ '{"windowRules":"not an array"}' \ '{"windowRules":[null]}' \ '{"windowRules":[42]}' \ '{"windowRules":[{"class":123}]}' \ '{"windowRules":[{"class":"ok","size":"400x600"}]}' \ '{"windowRules":[{"class":"ok","workspace":"3"}]}'; do out="$(emit "$hostile")" (( "$(count "$out")" >= shipped_count )) \ || note "a malformed windowRules value cost the shipped rules: $hostile" done # ── The preference cannot pretend to be an option ──────────────────────────── if grep -q 'key: "windowRules"' "$schema"; then block="$(sed -n '/key: "windowRules"/,/^ },/p' "$schema")" grep -q 'hypr:' <<<"$block" \ && note 'windowRules declares a hypr option, but window rules are not settable options' else note 'windowRules is not in the schema' fi # ── Report ─────────────────────────────────────────────────────────────────── if (( ${#findings[@]} > 0 )); then mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u) printf 'window rules contract: %d finding(s)\n' "${#findings[@]}" >&2 printf ' - %s\n' "${findings[@]}" >&2 exit 1 fi printf 'window rules contract: PASS (%d shipped rules, user rules validated and escaped)\n' "$shipped_count"