218 lines
9.9 KiB
Bash
Executable File
218 lines
9.9 KiB
Bash
Executable File
#!/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
|
|
# "<name or -> <class pattern> <field=value ...>". 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"
|