Shortcuts you invent, rules you write, gestures you own - all still just data
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
+204
-10
@@ -1,15 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The touchpad gestures, and the window swallowing beside them.
|
||||
# The touchpad gestures, and the two pieces of looks.lua beside them.
|
||||
#
|
||||
# Both are behaviour that only exists on hardware this machine may not have, so
|
||||
# neither can be checked by running it. What can be pinned is the shape:
|
||||
# All of it is behaviour that cannot be observed without hardware this machine
|
||||
# may not have, or without a compositor -- a swipe, a window swallowing
|
||||
# another, a shader over the whole screen. What can be pinned is the shape:
|
||||
#
|
||||
# * Three gestures, mirroring GNOME. Sideways moves workspaces, up opens the
|
||||
# overview, down closes it.
|
||||
# * Three THREE-finger gestures, mirroring GNOME. Sideways moves workspaces,
|
||||
# up opens the overview, down closes it. These are the desktop's and are
|
||||
# written as literals, so they can be read here character by character.
|
||||
# * Up and down do not both toggle. That is the obvious way to write it and it
|
||||
# is wrong: swiping up from an open overview would close it, and swiping
|
||||
# down would reopen it, which is the opposite of what the fingers mean.
|
||||
# * FOUR-finger gestures are the user's, from settings.json, and are emitted
|
||||
# only inside the loop that reads them -- one per direction that resolves to
|
||||
# a valid named action, and none at all for a direction nobody assigned.
|
||||
# That last part is not tidiness: a gesture registration is read at config
|
||||
# time and cannot be removed afterwards, so a no-op gesture per direction
|
||||
# would consume the four-finger swipes permanently.
|
||||
# * Every color filter the settings page offers names a shader the repository
|
||||
# actually ships. `decoration:screen_shader` is a path Hyprland compiles at
|
||||
# config time, so a missing one is a shader compile failure on a
|
||||
# whole-screen pass rather than a feature that quietly does nothing.
|
||||
# * Swallowing is off by default and driven by a preference. Turning it on for
|
||||
# everybody would make terminals appear to vanish on a machine nobody asked.
|
||||
# * The swallow regex names only terminals this desktop ships. Anything the
|
||||
@@ -19,17 +31,19 @@
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
input="$repo_dir/config/dot/hypr/input.lua"
|
||||
looks="$repo_dir/config/dot/hypr/looks.lua"
|
||||
hypr_dir="$repo_dir/config/dot/hypr"
|
||||
input="$hypr_dir/input.lua"
|
||||
looks="$hypr_dir/looks.lua"
|
||||
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
# ── Gestures ─────────────────────────────────────────────────────────────────
|
||||
# ── The three that are the desktop's ─────────────────────────────────────────
|
||||
|
||||
gestures="$(grep -c 'hl\.gesture({' "$input" || true)"
|
||||
(( gestures == 3 )) || note "input.lua registers $gestures gestures, not the 3 that mirror GNOME"
|
||||
literals="$(grep -c 'hl\.gesture({ fingers = 3' "$input" || true)"
|
||||
(( literals == 3 )) \
|
||||
|| note "input.lua registers $literals three-finger gestures, not the 3 that mirror GNOME"
|
||||
|
||||
grep -qE 'fingers = 3, direction = "horizontal",[[:space:]]*action = "workspace"' "$input" \
|
||||
|| note 'three fingers sideways does not switch workspaces'
|
||||
@@ -43,6 +57,111 @@ if grep -qE 'direction = "(up|down)",[[:space:]]*action = overview\("toggle"\)'
|
||||
note 'a vertical gesture toggles the overview, so swiping the same way twice undoes itself'
|
||||
fi
|
||||
|
||||
# ── The four that are the user's ─────────────────────────────────────────────
|
||||
#
|
||||
# Exactly one hl.gesture call beyond the three literals, and it lives inside the
|
||||
# loop over the preferences. A second call site is a four-finger gesture emitted
|
||||
# from somewhere the settings file does not control, which is a gesture nobody
|
||||
# can take back.
|
||||
|
||||
calls="$(grep -c 'hl\.gesture({' "$input" || true)"
|
||||
(( calls == 4 )) \
|
||||
|| note "input.lua has $calls hl.gesture call sites; expected the 3 literals plus one in the loop"
|
||||
|
||||
grep -q 'custom_gestures' "$input" \
|
||||
|| note 'the four-finger gestures are not read from a table of preferences'
|
||||
|
||||
python3 - "$input" <<'PY' || note 'a four-finger gesture is registered outside the loop that reads the preferences'
|
||||
import re, sys
|
||||
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
|
||||
in_loop = False
|
||||
problems = []
|
||||
for number, line in enumerate(lines, 1):
|
||||
if re.match(r"^for .*custom_gestures", line):
|
||||
in_loop = True
|
||||
elif re.match(r"^end\b", line):
|
||||
in_loop = False
|
||||
if "fingers = 4" in line and not in_loop:
|
||||
problems.append(f"line {number}: {line.strip()[:70]}")
|
||||
if problems:
|
||||
print("\n".join(problems), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
# ── What the Lua actually emits ──────────────────────────────────────────────
|
||||
#
|
||||
# The checks above read the source. This runs it, against a stubbed `hl` and a
|
||||
# synthetic settings file, so no compositor is touched and no real preference is
|
||||
# read -- and so the validation in actions.lua is exercised rather than assumed.
|
||||
|
||||
if command -v lua >/dev/null 2>&1; then
|
||||
work="$(mktemp -d /tmp/panama-gestures.XXXXXX)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
mkdir -p "$work/config/panama"
|
||||
|
||||
# The fingers count of every gesture input.lua registers, one per line.
|
||||
emit() {
|
||||
printf '%s' "$1" >"$work/config/panama/settings.json"
|
||||
XDG_CONFIG_HOME="$work/config" lua -e "
|
||||
package.path = '$hypr_dir/?.lua;' .. package.path
|
||||
hl = {
|
||||
config = function() end,
|
||||
exec_cmd = function() end,
|
||||
dispatch = function() end,
|
||||
gesture = function(spec) print(spec.fingers .. ' ' .. tostring(spec.direction)) end,
|
||||
}
|
||||
dofile('$input')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
four_fingers() { grep -c '^4 ' <<<"$1" || true; }
|
||||
three_fingers() { grep -c '^3 ' <<<"$1" || true; }
|
||||
|
||||
# Nothing assigned: the three shipped gestures and not one more.
|
||||
unassigned="$(emit '{}')"
|
||||
(( "$(three_fingers "$unassigned")" == 3 )) \
|
||||
|| note 'the three shipped gestures are not registered on a machine with no settings file'
|
||||
(( "$(four_fingers "$unassigned")" == 0 )) \
|
||||
|| note 'a four-finger gesture is registered with nothing assigned to it'
|
||||
|
||||
empty="$(emit '{"gestureFourUp":{},"gestureFourDown":{},"gestureFourLeft":{},"gestureFourRight":{}}')"
|
||||
(( "$(four_fingers "$empty")" == 0 )) \
|
||||
|| note 'an explicitly unassigned direction still registers a gesture'
|
||||
|
||||
# Two assigned, one per kind the vocabulary offers.
|
||||
assigned="$(emit '{"gestureFourUp":{"kind":"shell","target":"overview","label":"Overview"},
|
||||
"gestureFourLeft":{"kind":"window","target":"float-toggle","label":"Toggle float"}}')"
|
||||
(( "$(four_fingers "$assigned")" == 2 )) \
|
||||
|| note "two assigned directions produced $(four_fingers "$assigned") four-finger gestures"
|
||||
grep -q '^4 up$' <<<"$assigned" || note 'an assigned four-finger up gesture was not registered'
|
||||
grep -q '^4 left$' <<<"$assigned" || note 'an assigned four-finger left gesture was not registered'
|
||||
(( "$(three_fingers "$assigned")" == 3 )) \
|
||||
|| note 'assigning a four-finger gesture changed the three shipped ones'
|
||||
|
||||
# A stored value the whitelists do not recognise resolves to nothing. This
|
||||
# is the property that keeps a hand-editable file from being executable: a
|
||||
# command in `target` is not a command, it is an unknown key.
|
||||
hostile="$(emit '{"gestureFourUp":{"kind":"shell","target":"rm -rf /","label":"x"},
|
||||
"gestureFourDown":{"kind":"exec","target":"sh","label":"x"},
|
||||
"gestureFourLeft":{"kind":"window","target":"workspace:99","label":"x"},
|
||||
"gestureFourRight":{"kind":"app","target":"foo; reboot","label":"x"}}')"
|
||||
(( "$(four_fingers "$hostile")" == 0 )) \
|
||||
|| note 'an unrecognised or unsafe named action was registered as a gesture anyway'
|
||||
(( "$(three_fingers "$hostile")" == 3 )) \
|
||||
|| note 'a malformed gesture preference cost the shipped gestures'
|
||||
|
||||
# A malformed file must cost the customizations and nothing else.
|
||||
for broken in '{"gestureFourUp":"overview"}' '{"gestureFourUp":[1,2]}' '{"gestureFourUp":null}'; do
|
||||
(( "$(three_fingers "$(emit "$broken")")" == 3 )) \
|
||||
|| note "a malformed gesture preference took input.lua down: $broken"
|
||||
done
|
||||
|
||||
rm -rf "$work"
|
||||
trap - EXIT
|
||||
else
|
||||
note 'lua is not installed, so what the gestures actually emit went unchecked'
|
||||
fi
|
||||
|
||||
# The tuning that Hyprland does accept at runtime has to be reachable, or the
|
||||
# gestures are unadjustable without editing this file -- which is the thing
|
||||
# Settings exists to avoid.
|
||||
@@ -51,6 +170,81 @@ for key in swipeDistance swipeInvert; do
|
||||
|| note "$key is not a preference, so the gestures cannot be tuned from Settings"
|
||||
done
|
||||
|
||||
# ── Color filters ────────────────────────────────────────────────────────────
|
||||
#
|
||||
# looks.lua's other unrunnable half, and it sits here for the same reason
|
||||
# swallowing does: it is behaviour whose effect cannot be observed without a
|
||||
# compositor, but whose shape can be read.
|
||||
#
|
||||
# `decoration:screen_shader` is a PATH, and Hyprland compiles what it finds
|
||||
# there at config time. A named shader the repository does not ship is not a
|
||||
# filter that quietly does nothing -- it is a shader compile failure on a
|
||||
# whole-screen pass, which is a much worse afternoon than a missing feature.
|
||||
# So every enum the preference offers must name a file that exists.
|
||||
|
||||
filters="$(sed -n '/^local colorFilters = {/,/^}/p' "$looks" | grep -oE '[a-z]+\.frag')"
|
||||
[[ -n "$filters" ]] || note 'looks.lua has no enum-to-shader table, so the color filter preference reaches nothing'
|
||||
|
||||
while IFS= read -r shader; do
|
||||
[[ -n "$shader" ]] || continue
|
||||
[[ -f "$repo_dir/config/dot/hypr/shaders/$shader" ]] \
|
||||
|| note "looks.lua names shaders/$shader, which the repository does not ship"
|
||||
done <<<"$filters"
|
||||
|
||||
# Every option the schema offers is mapped, and nothing is mapped that the
|
||||
# schema does not offer. An unmapped enum is a filter the settings page lets
|
||||
# you pick and the compositor never applies.
|
||||
schema_options="$(sed -n '/key: "colorFilter"/,/^ },/p' "$schema" \
|
||||
| grep -oE 'value: "[a-z]+"' | sed -E 's/value: "(.*)"/\1/' | grep -v '^none$')"
|
||||
[[ -n "$schema_options" ]] || note 'the colorFilter schema entry offers no filters'
|
||||
|
||||
while IFS= read -r option; do
|
||||
[[ -n "$option" ]] || continue
|
||||
grep -q "^$option\.frag$" <<<"$filters" \
|
||||
|| note "the schema offers the '$option' filter, but looks.lua maps it to no shader"
|
||||
done <<<"$schema_options"
|
||||
|
||||
# "none" must not be in the table: it is the absence of a filter, and mapping it
|
||||
# to a shader would make turning the feature off cost a full-screen pass.
|
||||
grep -q '^none\.frag$' <<<"$filters" \
|
||||
&& note 'looks.lua maps the "none" filter to a shader, so turning the filter off still runs one'
|
||||
|
||||
# The stored value is the enum, never the path. Storing the path would put a
|
||||
# filesystem location into a hand-editable preference and make the stored value
|
||||
# disagree with what hyprctl reports back.
|
||||
if grep -q 'key: "colorFilter"' "$schema"; then
|
||||
# Comments stripped first: the entry explains at length why it has no
|
||||
# hypr: block, and the explanation contains the words it is denying.
|
||||
block="$(sed -n '/key: "colorFilter"/,/^ },/p' "$schema" | grep -v '^[[:space:]]*//')"
|
||||
grep -q 'hypr:' <<<"$block" \
|
||||
&& note 'colorFilter declares a hypr option, but hyprctl stores a shader path rather than this enum'
|
||||
else
|
||||
note 'colorFilter is not in the schema'
|
||||
fi
|
||||
|
||||
# Every shader is an end-of-pipe fragment shader in the form Hyprland's own
|
||||
# example uses. A shader missing `tex` or its output samples nothing and paints
|
||||
# nothing, which on a whole-screen pass is a black desktop.
|
||||
for shader in "$repo_dir"/config/dot/hypr/shaders/*.frag; do
|
||||
[[ -e "$shader" ]] || continue
|
||||
name="$(basename "$shader")"
|
||||
grep -q '^#version 300 es' "$shader" \
|
||||
|| note "shaders/$name does not declare the GLSL version Hyprland compiles screen shaders as"
|
||||
grep -q 'uniform sampler2D tex;' "$shader" \
|
||||
|| note "shaders/$name never samples the screen"
|
||||
grep -q 'in vec2 v_texcoord;' "$shader" \
|
||||
|| note "shaders/$name does not take the screen coordinate Hyprland provides"
|
||||
grep -qE 'out vec4 fragColor;' "$shader" \
|
||||
|| note "shaders/$name declares no output, so it paints nothing"
|
||||
grep -q 'fragColor =' "$shader" \
|
||||
|| note "shaders/$name never writes its output"
|
||||
# Alpha is carried through rather than assumed opaque: the pass runs over
|
||||
# the composited frame, and forcing it to 1.0 is how a filter comes to
|
||||
# paint over things that were meant to be see-through.
|
||||
grep -q 'pixColor.a' "$shader" \
|
||||
|| note "shaders/$name discards the alpha channel instead of carrying it through"
|
||||
done
|
||||
|
||||
# ── Swallowing ───────────────────────────────────────────────────────────────
|
||||
|
||||
grep -qE 'enable_swallow = prefs\.get\("windowSwallow", false\)' "$looks" \
|
||||
|
||||
@@ -45,6 +45,16 @@ grep -q 'write_categories()' "$keybinds" \
|
||||
grep -q 'if file == nil then' "$keybinds" \
|
||||
|| note 'the manifest writer does not tolerate being unable to open the file'
|
||||
|
||||
# The user's own shortcuts are a category too, and they are the one group whose
|
||||
# membership is not written in this file. They are emitted last and outside the
|
||||
# `bind` wrapper -- deliberately, so keybindOverrides (which is keyed by a
|
||||
# SHIPPED chord) can never reach one -- which means they would be invisible to
|
||||
# the manifest unless the loop records the category itself.
|
||||
grep -q 'category("Custom")' "$keybinds" \
|
||||
|| note 'custom shortcuts are emitted without a category, so they land in Other'
|
||||
grep -q 'customBinds' "$keybinds" \
|
||||
|| note 'keybinds.lua never reads customBinds, so shortcuts the user invents are not bound'
|
||||
|
||||
# The shell prefers the authored category and still works without one.
|
||||
grep -q 'categoryManifest' "$service" \
|
||||
|| note 'the shell never reads the category manifest'
|
||||
@@ -100,7 +110,14 @@ if [[ -r "$manifest" ]]; then
|
||||
|
||||
# Every category is one the shell knows how to order. A typo produces a
|
||||
# group that sorts last and looks like a bug in the cheatsheet.
|
||||
known='Windows Workspaces Applications Shell Session Media & hardware Other'
|
||||
#
|
||||
# Custom is the one group the user fills: shortcuts they invented, read
|
||||
# from customBinds at the end of keybinds.lua. It is legitimately empty on
|
||||
# a machine nobody has customized and legitimately full on one somebody
|
||||
# has, so unlike Other it carries no count expectation -- only the
|
||||
# requirement that it be a name the shell orders (Keybinds.qml's groupOrder
|
||||
# puts it first) rather than an unknown that sorts last.
|
||||
known='Windows Workspaces Applications Shell Session Media & hardware Custom Other'
|
||||
while read -r value; do
|
||||
[[ -n "$value" ]] || continue
|
||||
grep -qF "$value" <<<"$known" \
|
||||
|
||||
Executable
+217
@@ -0,0 +1,217 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user