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:
@@ -87,6 +87,202 @@ rg -Fq 'Restore every shipped shortcut' "$page" \
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user