#!/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"(?/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 "\t\t". 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'