Files
Panama/tests/quickshell/keybind-rebind-contract

372 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
# Rebinding a keyboard shortcut.
#
# This is the highest-consequence write in the settings app: a mistake here
# costs the user their keymap, and the keymap is how they reach everything else.
# The properties that matter:
#
# * an override moves exactly the bind it names and nothing else -- keying by
# description moved every bind sharing one, which silently cost the
# XF86Calculator hardware key when SUPER+C was rebound;
# * only the chord is ever stored, never the action;
# * a chord already in use is refused rather than shadowing the existing bind;
# * resetting returns the exact shipped keymap.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/keybinds-harness.qml"
service="$repo_dir/config/dot/quickshell/services/Keybinds.qml"
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
page="$settings_dir/ShortcutsPage.qml"
row="$settings_dir/ShortcutRow.qml"
fail() {
printf 'keybind rebind contract: %s\n' "$1" >&2
exit 1
}
rg -Fq 'function overrideOccupantFor(chord: string, exceptShipped: string): string' "$service" \
|| fail 'resetBind has no override collision guard'
rg -Fq 'root.overrideOccupantFor(shipped, shipped)' "$service" \
|| fail 'resetBind does not check whether another override occupies its shipped chord'
# ── The page that drives all of the above ────────────────────────────────────
#
# The engine is exercised for real below, but the engine is only reachable
# through one screen, and the screen has been rebuilt around a per-row
# component. These pin the parts of that screen that carry consequence: every
# one of them is a way to lose the rebinding flow without any test noticing,
# because the service would still be perfectly correct.
#
# The row and the page are read as one source, so moving a control between them
# is not a failure -- removing it is.
[[ -r "$page" ]] || fail "cannot read $page"
[[ -r "$row" ]] || fail "cannot read $row -- the shortcut row component is gone"
browser="$(cat "$page" "$row")"
# The capture field is shared with nothing else and is the only thing in the
# tree that reads a chord without acting on it. A page that grew its own key
# handler instead would capture SUPER as a bind of its own.
grep -Fq 'ShortcutCapture' <<<"$browser" \
|| fail 'the shortcuts browser no longer uses ShortcutCapture, so something else is reading key presses'
for needle in \
'Keybinds.boundTo(' \
'Keybinds.rebind(' \
'Keybinds.resetBind(' \
'Keybinds.resetAll()' \
'Keybinds.isOverridden(' \
'text: "Change"' \
'text: "Reset"'; do
grep -Fq "$needle" <<<"$browser" \
|| fail "the shortcuts browser is missing $needle"
done
# boundTo before rebind. Without the check the write still succeeds and two
# actions end up on one chord, with whichever Hyprland reads last winning.
conflict_line="$(grep -n 'Keybinds.boundTo(' <<<"$browser" | head -1 | cut -d: -f1)"
write_line="$(grep -n 'Keybinds.rebind(' <<<"$browser" | head -1 | cut -d: -f1)"
[[ -n "$conflict_line" && -n "$write_line" && "$conflict_line" -lt "$write_line" ]] \
|| fail 'the chord in use is not checked before the rebind is written'
# Keyed by the shipped Lua chord, never by the description. Keying by
# description is what once moved every bind that shared one, silently costing
# the XF86Calculator hardware key when SUPER+C was rebound.
grep -Fq 'luaChord' <<<"$browser" \
|| fail 'the browser does not identify binds by their shipped Lua chord'
if grep -qE 'Keybinds\.(rebind|resetBind)\([^)]*description' <<<"$browser"; then
fail 'a rebind or reset is keyed by description, which moves every bind that shares one'
fi
# Restoring everything stays reachable, and says how much it would undo.
rg -Fq 'Restore every shipped shortcut' "$page" \
|| fail 'the restore-all row is gone'
rg -Fq 'Object.keys(Keybinds.overrides).length' "$page" \
|| fail 'the restore-all row no longer counts what it would put back'
# ── Shortcuts the user invented ──────────────────────────────────────────────
#
# Overrides move a shipped bind and can only ever carry a chord, which is what
# the checks above are about. Custom shortcuts are the harder case: the stored
# entry has to describe an ACTION, and the file it is stored in is one the user
# can open in a text editor.
#
# It stays non-executable, and hypr/actions.lua is the whole reason. A stored
# entry is { chord, kind, target, label }: `kind` is an enum with three
# members, and `target` either names a key of a whitelist table whose values
# are command strings written in Lua by a human, or -- for an application -- is
# an identifier restricted to a character class containing no shell
# metacharacter, quoted as a single argv element for the launch-or-focus path.
#
# There is no third path. Nothing stored anywhere contributes a character to a
# command string. These pin that, because it is the property that makes the
# whole feature safe rather than a config-file injection with a settings page.
actions="$repo_dir/config/dot/hypr/actions.lua"
keybinds="$repo_dir/config/dot/hypr/keybinds.lua"
input_lua="$repo_dir/config/dot/hypr/input.lua"
[[ -r "$actions" ]] || fail "cannot read $actions -- the named-action resolver is gone"
# One resolver, required by both the things that resolve names.
grep -Fq 'require("actions")' "$keybinds" \
|| fail 'keybinds.lua does not use the named-action resolver'
grep -Fq 'require("actions")' "$input_lua" \
|| fail 'input.lua does not use the named-action resolver, so gestures resolve names some other way'
# The target character class. Written as a Lua pattern, so `-` is escaped as
# `%-`; the length bound is a separate check because Lua patterns have no {n,m}.
grep -Fq '"^[A-Za-z0-9@._%-]+$"' "$actions" \
|| fail 'the application target pattern is not the safe character class'
grep -Fq '#target <= 128' "$actions" \
|| fail 'the application target has no length bound'
# Every exec string in actions.lua comes from a table literal in actions.lua.
# The one place a stored value reaches a command is the launch-or-focus path,
# and there it is shell_quote()d -- an argument, not a fragment of a command.
python3 - "$actions" <<'PY' || fail 'actions.lua builds a command out of something other than its own whitelist tables'
import re, sys
source = open(sys.argv[1], encoding="utf-8").read()
# Whole-line comments only. A `--` anywhere else in a Lua line may well be
# inside a string -- "--class" is an argument this very file passes -- and
# treating it as a comment blinds the scan to the rest of the line.
lines = ["" if l.lstrip().startswith("--") else l for l in source.splitlines()]
problems = []
# Anything that puts `target` into a string being concatenated. Exactly two
# forms are permitted, both of which make it one quoted argv element rather
# than a fragment of a command; they are matched literally, so any third way of
# reaching a command string is a finding rather than a regex to be outwitted.
PERMITTED = (
'shell_quote("^" .. escape_regex(target) .. "$")',
"shell_quote(target)",
)
for number, line in enumerate(lines, 1):
if "target" not in line:
continue
stripped = line
for permitted in PERMITTED:
stripped = stripped.replace(permitted, "")
if re.search(r"\.\.\s*[A-Za-z_.]*target|target\s*\.\.", stripped):
problems.append(f"line {number}: {line.strip()[:80]}")
# The command strings themselves are literals in the whitelist table.
for match in re.finditer(r"(?<![\w.])command\s*=\s*([^,\n]+)", source):
value = match.group(1).strip()
if not value.startswith('"'):
problems.append(f"a whitelist command is not a literal: {value[:60]}")
# exec_cmd is only ever handed a whitelist command or the launcher command
# assembled from literals above it.
for number, line in enumerate(lines, 1):
m = re.search(r"exec_cmd\(([^)]*)\)", line)
if m and m.group(1).strip() not in ("verb.command", "launch_command"):
problems.append(f"line {number}: exec_cmd takes {m.group(1).strip()[:60]}")
if problems:
print("\n".join(problems), file=sys.stderr)
raise SystemExit(1)
PY
# ── What the Lua actually emits ──────────────────────────────────────────────
#
# The static read above says the code is shaped right. This runs it, with a
# stubbed `hl` and a synthetic settings file, so the validation is exercised
# rather than trusted -- no compositor, no real preferences.
if command -v lua >/dev/null 2>&1; then
lua_work="$(mktemp -d /tmp/panama-custom-binds.XXXXXX)"
mkdir -p "$lua_work/config/panama" "$lua_work/state"
# Every bind keybinds.lua emits, as "<chord>\t<description>\t<action>".
emit_binds() {
printf '%s' "$1" >"$lua_work/config/panama/settings.json"
XDG_CONFIG_HOME="$lua_work/config" XDG_STATE_HOME="$lua_work/state" lua -e "
package.path = '$repo_dir/config/dot/hypr/?.lua;' .. package.path
hl = {
config = function() end,
dispatch = function() end,
bind = function(chord, action, opts)
print(chord .. '\t' .. tostring((opts or {}).description) .. '\t' .. tostring(action))
end,
dsp = setmetatable({}, { __index = function(_, name)
local function node(path)
return setmetatable({}, {
__index = function(_, key) return node(path .. '.' .. key) end,
__call = function(_, argument)
if type(argument) == 'string' then
return path .. '(' .. argument .. ')'
end
return path .. '()'
end,
})
end
return node(name)
end }),
}
dofile('$keybinds')
" 2>/dev/null
}
baseline="$(emit_binds '{}' | wc -l)"
(( baseline > 100 )) || fail "the shipped keymap emitted $baseline binds, which cannot be right"
# Two good entries, and seven ways of being wrong: an unknown shell verb, a
# command in the target, a command in an app id, a workspace outside 1..10,
# a kind nobody defined, an empty label, and a chord already taken by a
# shipped bind.
shipped_chord="$(emit_binds '{}' | cut -f1 | grep -Fx 'SUPER + T')"
[[ -n "$shipped_chord" ]] || fail 'could not find a shipped chord to collide with'
custom="$(emit_binds '{"customBinds":[
{"chord":"SUPER + SHIFT + F1","kind":"shell","target":"dnd-toggle","label":"Do Not Disturb"},
{"chord":"SUPER + SHIFT + F2","kind":"app","target":"org.gnome.Nautilus","label":"Files"},
{"chord":"SUPER + SHIFT + F3","kind":"window","target":"workspace:4","label":"Workspace 4"},
{"chord":"SUPER + SHIFT + F4","kind":"shell","target":"reboot","label":"Unknown verb"},
{"chord":"SUPER + SHIFT + F5","kind":"shell","target":"dnd-toggle; reboot","label":"Command"},
{"chord":"SUPER + SHIFT + F6","kind":"app","target":"foo $(reboot)","label":"Command in an id"},
{"chord":"SUPER + SHIFT + F7","kind":"window","target":"workspace:0","label":"No such workspace"},
{"chord":"SUPER + SHIFT + F8","kind":"exec","target":"reboot","label":"Invented kind"},
{"chord":"SUPER + SHIFT + F9","kind":"shell","target":"overview","label":""},
{"chord":"SUPER + T","kind":"shell","target":"lock","label":"Steals the terminal key"}
]}')"
added=$(( $(wc -l <<<"$custom") - baseline ))
(( added == 3 )) || fail "ten custom binds with seven invalid added $added binds, not 3"
for chord in 'SUPER + SHIFT + F1' 'SUPER + SHIFT + F2' 'SUPER + SHIFT + F3'; do
grep -Fq "$chord" <<<"$custom" || fail "the valid custom bind $chord was not emitted"
done
# The shipped key kept its action. A custom bind that collides loses; the
# alternative is two binds on one chord and whichever Hyprland reads last.
terminal_line="$(grep -F "$shipped_chord"$'\t' <<<"$custom" | head -1)"
grep -Fq 'Terminal' <<<"$terminal_line" \
|| fail "a custom bind took over a shipped chord: $terminal_line"
# Every custom bind carries the label as its description, because a bind
# with no description is invisible to the cheatsheet and to the page that
# would let you change it.
while IFS=$'\t' read -r chord description _; do
[[ -n "$description" && "$description" != "nil" ]] \
|| fail "the bind $chord has no description"
done <<<"$custom"
# And the actions are only ever whitelist commands or a quoted launch.
while IFS=$'\t' read -r _ _ action; do
case "$action" in
*reboot*) fail "a stored target reached a command: $action" ;;
esac
done <<<"$custom"
grep -Fq "panama-launch --class '^org\\.gnome\\.Nautilus\$' -- gtk-launch 'org.gnome.Nautilus'" <<<"$custom" \
|| fail "the app target is not passed as a quoted argument to the launch-or-focus path: $(grep -F 'SUPER + SHIFT + F2' <<<"$custom")"
# Chords have a bound, and it is the same one overrides have. A 4 KB
# "chord" is not a chord, it is a way to make hyprctl binds unreadable.
long_chord="$(printf 'A%.0s' $(seq 1 65))"
over="$(emit_binds "{\"customBinds\":[{\"chord\":\"$long_chord\",\"kind\":\"shell\",\"target\":\"lock\",\"label\":\"Long\"}]}" | wc -l)"
(( over == baseline )) || fail 'a chord longer than 64 characters was bound anyway'
# A malformed file costs the customizations and never the keymap.
for broken in '{"customBinds":"nope"}' '{"customBinds":[null]}' '{"customBinds":[{"chord":42}]}'; do
(( "$(emit_binds "$broken" | wc -l)" == baseline )) \
|| fail "a malformed customBinds value changed the shipped keymap: $broken"
done
rm -rf "$lua_work"
else
fail 'lua is not installed, so what a custom shortcut becomes went unchecked'
fi
if [[ "${PANAMA_KEYBINDS_STATIC_ONLY:-0}" == "1" ]]; then
printf 'keybind rebind contract: PASS (static)\n'
exit 0
fi
config_home="$(mktemp -d /tmp/panama-rebind-config.XXXXXX)"
# The compositor is the live one -- that is the point -- but preferences are
# isolated so this cannot leave an override in the user's real settings.
# hyprctl reload re-reads the real settings file, so the compositor is only
# exercised through the shipped configuration here; the override logic itself is
# what is under test.
qs_for_harness() {
XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"
}
cleanup() {
qs_for_harness ipc call keybinds-test resetAll >/dev/null 2>&1 || true
qs_for_harness kill >/dev/null 2>&1 || true
rm -rf "$config_home"
}
trap cleanup EXIT
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
qs_for_harness ipc show 2>/dev/null | rg -q '^target keybinds-test$' && break
sleep 0.1
done
qs_for_harness ipc show 2>/dev/null | rg -q '^target keybinds-test$' || fail 'test IPC target did not start'
for _ in $(seq 1 40); do
[[ "$(qs_for_harness ipc call keybinds-test status | jq -r .loaded)" == "true" ]] && break
sleep 0.1
done
# ── Nothing is overridden to begin with ──────────────────────────────────────
[[ "$(qs_for_harness ipc call keybinds-test overrideState | jq -r .count)" == "0" ]] \
|| fail 'the isolated store started with overrides'
# ── A chord already in use is refused ────────────────────────────────────────
terminal="$(qs_for_harness ipc call keybinds-test chordFor Terminal)"
[[ -n "$terminal" ]] || fail 'could not find the Terminal bind'
files="$(qs_for_harness ipc call keybinds-test chordFor Files)"
[[ -n "$files" ]] || fail 'could not find the Files bind'
[[ "$(qs_for_harness ipc call keybinds-test rebind "$terminal" "$files")" == "false" ]] \
|| fail 'rebinding onto a chord already in use was accepted'
[[ "$(qs_for_harness ipc call keybinds-test overrideState | jq -r .count)" == "0" ]] \
|| fail 'a refused rebind still stored an override'
# ── A rebind stores only the chord, keyed by the shipped chord ───────────────
[[ "$(qs_for_harness ipc call keybinds-test rebind "$terminal" "SUPER + SHIFT + F9")" == "true" ]] \
|| fail 'a valid rebind was refused'
state="$(qs_for_harness ipc call keybinds-test overrideState)"
jq -e --arg k "$terminal" '.overrides[$k] == "SUPER + SHIFT + F9"' <<<"$state" >/dev/null \
|| fail "the override was not keyed by the shipped chord: $state"
jq -e '.count == 1' <<<"$state" >/dev/null || fail "exactly one override expected: $state"
# Only a chord is stored. Nothing resembling an action or command may appear,
# because that is what keeps a user-editable file from being executable.
jq -e '[.overrides[]] | all(type == "string" and (length < 64))' <<<"$state" >/dev/null \
|| fail 'an override value is not a plain chord'
# ── Reset clears it ──────────────────────────────────────────────────────────
qs_for_harness ipc call keybinds-test resetAll >/dev/null
sleep 0.5
[[ "$(qs_for_harness ipc call keybinds-test overrideState | jq -r .count)" == "0" ]] \
|| fail 'resetAll left overrides behind'
# Terminal moved away from its shipped chord, then Files moved into it.
# Resetting Terminal must refuse instead of producing two binds on one chord.
qs_for_harness ipc call keybinds-test seedResetCollision \
"$terminal" "SUPER + SHIFT + F9" "$files" >/dev/null
sleep 0.2
[[ "$(qs_for_harness ipc call keybinds-test resetBind "SUPER + SHIFT + F9")" == "false" ]] \
|| fail 'resetBind reclaimed a shipped chord occupied by another override'
collision_state="$(qs_for_harness ipc call keybinds-test overrideState)"
jq -e --arg terminal "$terminal" --arg files "$files" \
'.count == 2 and .overrides[$terminal] == "SUPER + SHIFT + F9" and .overrides[$files] == $terminal and (.lastError | length > 0)' \
<<<"$collision_state" >/dev/null \
|| fail "a refused reset changed overrides or gave no explanation: $collision_state"
trap - EXIT
cleanup
printf 'keybind rebind contract: PASS\n'