Skip to content
70 changes: 65 additions & 5 deletions bin/omarchy-menu-keybindings
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,10 @@ build_lua_bind_cache() {
LUA_BIND_DISPATCHER_MAP["$cache_key"]="$dispatcher"
LUA_BIND_ARG_MAP["$cache_key"]="$arg"
done < <(
# -E keeps LUA_INIT and LUA_PATH from the environment out of the scan, which
# loads the user's config.
lua -E - <<'LUA'
# A config the stub cannot satisfy must not leave a runaway lua behind after
# the menu closes, so the scan is time-boxed. -E keeps LUA_INIT and LUA_PATH
# from the environment out of the scan, which loads the user's config.
timeout 10 lua -E - <<'LUA'
local modifiers = { SHIFT = 1, CTRL = 4, CONTROL = 4, ALT = 8, SUPER = 64 }

local function split_keys(keys)
Expand Down Expand Up @@ -227,14 +228,67 @@ local function dsp_proxy(path)
})
end

-- Stand-in for every Hyprland value the config reads while we only want its
-- binds. It has to be safe for the loops and comparisons user configs run at
-- load time: a numeric index answers nil so `ipairs(hl.get_monitors())`
-- terminates instead of spinning a 100% CPU `lua` forever, and comparisons,
-- arithmetic, and concatenation degrade to harmless values instead of raising
-- and aborting the scan halfway through the config.
local noop
noop = setmetatable({}, {
__index = function()
__index = function(_, key)
if type(key) == "number" then
return nil
end

return noop
end,
__call = function()
return noop
end,
__len = function()
return 0
end,
__lt = function()
return false
end,
__le = function()
return false
end,
__unm = function()
return 0
end,
__add = function()
return 0
end,
__sub = function()
return 0
end,
__mul = function()
return 0
end,
__div = function()
return 0
end,
__mod = function()
return 0
end,
__idiv = function()
return 0
end,
__pow = function()
return 0
end,
__concat = function(left, right)
if rawequal(left, noop) then
return tostring(right)
end

return tostring(left)
end,
__tostring = function()
return ""
end,
})

hl = setmetatable({
Expand Down Expand Up @@ -277,7 +331,13 @@ local file = io.open(config, "r")

if file then
file:close()
-- Stops a runaway loop in the config long before the timeout above, which
-- stays the backstop for one the hook cannot reach, such as in a coroutine.
debug.sethook(function()
error("instruction limit exceeded")
end, "", 50000000)
local ok, err = pcall(dofile, config)
debug.sethook()
if not ok and os.getenv("DEBUG") == "1" then
io.stderr:write("[DEBUG] lua bind scan failed: " .. tostring(err) .. "\n")
end
Expand Down Expand Up @@ -609,7 +669,7 @@ output_binding_records_uncached() {

keybindings_cache_key() {
{
printf 'v17\n'
printf 'v18\n'
leading_keyboard
omarchy-language 2>/dev/null
cat "$OMARCHY_PATH/default/i18n/$(omarchy-language 2>/dev/null)/keybindings.json" 2>/dev/null
Expand Down
19 changes: 19 additions & 0 deletions test/shell.d/keybindings-menu-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,25 @@ grep -qP '→ Terminal\texec\tomarchy-launch-terminal$' "$tmpdir"/cache/omarchy/
fail "picking the terminal bind from the menu launches a terminal" "$(cat "$tmpdir"/cache/omarchy/keybindings-*.records)"
pass "picking the terminal bind from the menu launches a terminal"

# A plugin that walks the window list while it loads must not wedge the scan,
# and the binds after it must still be recovered from the Lua source.
{
printf 'for _ in ipairs(hl.get_windows()) do\n local window = nil\nend\n'
cat "$home/.config/hypr/hyprland.lua"
} >"$tmpdir/hyprland.lua"
mv "$tmpdir/hyprland.lua" "$home/.config/hypr/hyprland.lua"

rm -rf "$tmpdir/cache"
# A wedged scan is stopped by the menu's own timeout before the Terminal bind,
# which the check below catches; the outer timeout only turns a hang into a fail.
timeout --kill-after=1s 60s env -i PATH="$stub_bin:$ROOT/bin:$PATH" HOME="$home" \
XDG_CACHE_HOME="$tmpdir/cache" OMARCHY_PATH="$ROOT" \
bash "$menu" --print >/dev/null 2>&1 ||
fail "a plugin iterating windows at load time does not wedge the keybindings scan"
grep -qP '→ Terminal\texec\tomarchy-launch-terminal$' "$tmpdir"/cache/omarchy/keybindings-*.records ||
fail "a plugin iterating windows at load time does not wedge the keybindings scan (it stopped before the Terminal bind)"
pass "a plugin iterating windows at load time does not wedge the keybindings scan"

# Rows show their description in the Omarchy language: a numbered one
# matches its %1 pattern, an unknown one stays as written, and the chord
# column and what the row runs never change.
Expand Down
68 changes: 68 additions & 0 deletions test/shell.d/menu-keybindings-lua-scan-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/bin/bash

set -euo pipefail

source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"

require_command lua

# The keybindings menu learns about Lua-only binds by running the user's
# hyprland.lua under a stub `hl`. That stub has to survive whatever a config
# does at load time, or the scan hangs (one user config spun a `lua` at 100%
# CPU for 27 hours after a single SUPER+K) or aborts halfway, silently dropping
# every bind declared after the failing line.

script="$ROOT/bin/omarchy-menu-keybindings"
scan=$(awk '/lua (-E - )?<<.LUA.$/ { capture = 1; next } /^LUA$/ { capture = 0 } capture' "$script")
[[ -n $scan ]] || fail "keybindings menu embeds the Lua bind scan"

TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT

mkdir -p "$TMPDIR/home/.config/hypr"
cat >"$TMPDIR/home/.config/hypr/hyprland.lua" <<'LUA'
-- Load-time reads a real config does against the compositor state.
for _, monitor in ipairs(hl.get_monitors()) do
hl.monitor({ output = monitor.name, scale = monitor.scale })
end

local monitor = hl.get_active_monitor()
if not monitor or not monitor.scale or monitor.scale <= 0 then
error("comparison should not raise")
end
local reserved = monitor.height - monitor.reserved.top * monitor.scale
local label = "monitor " .. monitor.name .. " " .. tostring(monitor.scale)
if #hl.get_workspaces() ~= 0 then
error("stub collections should be empty")
end
if monitor.scale > 1 then
error("stub comparisons should be false")
end

hl.bind("SUPER + K", hl.dsp.exec_cmd("omarchy-menu keybindings"), { description = "Keybindings menu" })
hl.bind("SUPER + code:20", hl.dsp.workspace({ name = "special" }), { description = "Lua only bind" })
LUA

# No time budget, so a slow machine cannot fail it: a scan that never ended
# would be killed by the timeout, which fails the run on its exit status.
output=$(HOME="$TMPDIR/home" DEBUG=1 timeout 60 lua -E - <<<"$scan" 2>&1) || fail "Lua bind scan exits cleanly on a config that iterates and compares compositor state (output: $output)"
pass "Lua bind scan survives load-time loops, comparisons, and arithmetic over stub state"

[[ $output != *"lua bind scan failed"* ]] || fail "Lua bind scan does not abort on stub comparisons ($output)"
grep -qF $'64\tKeybindings menu\tK\texec\tomarchy-menu keybindings' <<<"$output" || fail "Lua bind scan reports exec binds declared after load-time reads (got: $output)"
grep -qF $'64\tLua only bind\tcode:20\tlua\thl.dsp.workspace({ name = "special" })' <<<"$output" || fail "Lua bind scan reports Lua dispatcher binds (got: $output)"
pass "Lua bind scan reports binds declared after load-time compositor reads"

grep -q "timeout 10 lua -E - <<'LUA'" "$script" || fail "Lua bind scan is time-boxed"
pass "Lua bind scan is time-boxed"

# A loop nothing in the stub can end is stopped inside the scan, well before
# the timeout, and the binds declared ahead of it are still reported.
cat >"$TMPDIR/home/.config/hypr/hyprland.lua" <<'LUA'
hl.bind("SUPER + K", hl.dsp.exec_cmd("omarchy-menu keybindings"), { description = "Keybindings menu" })
while true do end
LUA

output=$(HOME="$TMPDIR/home" DEBUG=1 timeout 60 lua -E - <<<"$scan" 2>&1) || fail "Lua bind scan stops a runaway config instead of running until killed (output: $output)"
grep -qF $'64\tKeybindings menu\tK\texec\tomarchy-menu keybindings' <<<"$output" || fail "Lua bind scan keeps binds declared before a runaway loop (got: $output)"
pass "Lua bind scan stops a runaway config and keeps the binds declared before it"
Loading