Files

227 lines
11 KiB
Lua

-- ─────────────────────────────────────────────────────────────────────────────
-- Named actions
--
-- The one thing that makes it safe for settings.json to describe a shortcut.
--
-- Panama Settings lets a person invent a keyboard shortcut and assign a
-- four-finger gesture. Both are stored in the same user-editable JSON file the
-- rest of the desktop reads, and both have to end up as something the
-- compositor executes -- which is exactly the shape of every configuration
-- format that turned out to be a shell injection.
--
-- It is not one here, and this file is why. A stored action is DATA:
--
-- { kind = "app" | "shell" | "window", target = "<id>", label = "<text>" }
--
-- `kind` is an enum with three members. `target` is either a key of one of the
-- whitelist tables below -- whose values are literals written here, in Lua, by
-- a human -- or, for `app`, an identifier that has to match a character class
-- containing no shell metacharacter at all, and which is then quoted as a
-- single argv element for panama-launch rather than pasted into a command.
--
-- So the worst a hand-edited (or maliciously written) settings file can do is
-- pick a different entry from a list that is fixed at ship time, or launch an
-- application by id. It cannot introduce a command. There is no path from a
-- stored string to a new exec string; the table lookups are the only source of
-- one.
--
-- Everything invalid returns nil and the caller skips the bind or gesture --
-- the prefs.lua philosophy: never raise, never guess. A malformed entry costs
-- one shortcut, never the keymap and never the compositor.
--
-- Required by keybinds.lua (custom shortcuts) and input.lua (four-finger
-- gestures). services/Keybinds.qml's describeAction() is the QML mirror of the
-- vocabulary below; the two lists have to be edited together.
-- ─────────────────────────────────────────────────────────────────────────────
local actions = {}
-- ── Shell verbs ─────────────────────────────────────────────────────────────
--
-- Every entry is a command string written HERE. Nothing stored anywhere else
-- contributes a character to one; `target` only chooses which of these to use.
--
-- The `qs ipc call` targets and functions are the ones quickshell/shell.qml
-- actually registers -- an IpcHandler silently declines to register a function
-- it cannot type-check, so a verb invented here would be a shortcut that does
-- nothing. Checked against shell.qml, not remembered.
--
-- The three that are not IPC (`launcher`, `color-picker`, `lock`) are the same
-- literal commands the shipped binds in keybinds.lua use, for the same reason
-- they use them: they are the tools, not the shell.
local SHELL = {
["dnd-toggle"] = { label = "Do Not Disturb", command = "qs ipc call notifications dnd" },
["notifications"] = { label = "Notifications", command = "qs ipc call notifications toggle" },
["overview"] = { label = "Overview", command = "qs ipc call overview toggle" },
["launcher"] = { label = "Launcher", command = "vicinae toggle" },
["clipboard"] = { label = "Clipboard history", command = "qs ipc call clipboard toggle" },
["screenshot"] = { label = "Screenshot / record", command = "qs ipc call capture open" },
["screenshot-screen"] = { label = "Screenshot: whole screen", command = "qs ipc call capture screenNow" },
["screenshot-window"] = { label = "Screenshot: window", command = "qs ipc call capture windowNow" },
["screen-intelligence"]= { label = "Screen Intelligence", command = "qs ipc call screen-intelligence open" },
["color-picker"] = { label = "Color picker", command = "hyprpicker -a -f hex" },
["quick-settings"] = { label = "Quick settings", command = "qs ipc call quicksettings toggle" },
["settings"] = { label = "Settings", command = "qs ipc call settings toggle" },
["cheatsheet"] = { label = "Keyboard shortcuts", command = "qs ipc call cheatsheet toggle" },
["focus-session"] = { label = "Focus session", command = "qs ipc call focus reveal" },
["caffeine"] = { label = "Keep awake", command = "qs ipc call caffeine toggle" },
["night-light"] = { label = "Night Light", command = "qs ipc call night-light toggle" },
["activity"] = { label = "Activity", command = "qs ipc call activity toggle" },
["power-menu"] = { label = "Power menu", command = "qs ipc call powermenu toggle" },
["lock"] = { label = "Lock", command = "loginctl lock-session" },
}
-- ── Window verbs ────────────────────────────────────────────────────────────
--
-- Builders rather than dispatchers, so nothing is constructed for a verb that
-- is never chosen, and so a gesture builds its dispatcher when the fingers
-- move rather than holding one from config time.
--
-- `workspace:N` is not in the table: it is ten entries that differ by a number,
-- and the number is validated as 1..10 in `window_action` below.
local WINDOW = {
["float-toggle"] = { label = "Toggle float", build = function() return hl.dsp.window.float({ action = "toggle" }) end },
["fullscreen"] = { label = "Fullscreen", build = function() return hl.dsp.window.fullscreen({ mode = "fullscreen" }) end },
["pin"] = { label = "Pin window", build = function() return hl.dsp.window.pin({ action = "toggle" }) end },
}
-- Published so a contract can read the vocabulary without parsing this file,
-- and so the ten workspace verbs have one definition rather than two.
actions.shell_verbs = SHELL
actions.window_verbs = WINDOW
actions.workspace_min = 1
actions.workspace_max = 10
-- ── Targets ─────────────────────────────────────────────────────────────────
-- Letters, digits, and the four punctuation marks a desktop id actually uses.
-- Deliberately excludes every shell metacharacter, quote, slash and space, so
-- an id that passes cannot change the meaning of a command line even before it
-- is quoted -- the quoting below is the second lock on the same door.
local APP_TARGET = "^[A-Za-z0-9@._%-]+$"
local function valid_app_target(target)
return type(target) == "string"
and #target >= 1 and #target <= 128
and target:match(APP_TARGET) ~= nil
end
-- Single-quoted for the shell, with the one escape single quotes need. Same
-- function keybinds.lua uses for the go-to patterns, and used here for the
-- same reason: the value reaches the command as one argument.
local function shell_quote(value)
return "'" .. value:gsub("'", "'\\''") .. "'"
end
-- Google RE2 metacharacters, escaped so the id matches itself literally.
-- panama-launch takes a regular expression, and an unescaped "org.gnome.Files"
-- would also match "orgxgnomexFiles".
local function escape_regex(value)
return (value:gsub("[%^%$%(%)%%%.%[%]%*%+%-%?%{%}%|\\]", "\\%0"))
end
-- ── Resolution ──────────────────────────────────────────────────────────────
local launcher_bin = "$HOME/.local/share/Panama/bin/panama-launch"
-- The launch-or-focus path the shipped application keys use: raise the window
-- if it is already open, start it if it is not. The id is the class pattern
-- (anchored, escaped) and the thing to start; on Wayland an application's
-- desktop id and its window class are the same string often enough that this
-- is the right first guess, and the wrong guess costs a second window rather
-- than an error.
--
-- gtk-launch activates a desktop entry by id, which is what the applications
-- catalog in Settings offers -- a desktop id is not a binary and cannot be
-- exec'd directly.
local function app_action(target)
if not valid_app_target(target) then
return nil
end
local launch_command = table.concat({
launcher_bin,
"--class", shell_quote("^" .. escape_regex(target) .. "$"),
"--", "gtk-launch", shell_quote(target),
}, " ")
return function() return hl.dsp.exec_cmd(launch_command) end
end
local function shell_action(target)
if type(target) ~= "string" then
return nil
end
local verb = SHELL[target]
if verb == nil then
return nil
end
return function() return hl.dsp.exec_cmd(verb.command) end
end
local function window_action(target)
if type(target) ~= "string" then
return nil
end
local verb = WINDOW[target]
if verb ~= nil then
return verb.build
end
local index = target:match("^workspace:(%d+)$")
if index == nil then
return nil
end
local number = tonumber(index)
if number == nil or number < actions.workspace_min or number > actions.workspace_max then
return nil
end
return function() return hl.dsp.focus({ workspace = number }) end
end
local KINDS = {
app = app_action,
shell = shell_action,
window = window_action,
}
-- The builder for one stored entry, or nil when the entry is anything this
-- file does not recognise. Everything above funnels through here, so there is
-- exactly one place where a stored value becomes an action.
local function builder(entry)
if type(entry) ~= "table" then
return nil
end
local resolve = KINDS[entry.kind]
if resolve == nil then
return nil
end
return resolve(entry.target)
end
-- For hl.bind: the dispatcher itself, or nil.
function actions.dispatcher(entry)
local build = builder(entry)
if build == nil then
return nil
end
return build()
end
-- For hl.gesture: a function, which is what a gesture action has to be when it
-- is not one of Hyprland's own built-in names ("workspace" and friends).
function actions.gesture(entry)
local build = builder(entry)
if build == nil then
return nil
end
return function() hl.dispatch(build()) end
end
-- True when an entry resolves to something. Cheap enough to call twice; used
-- where the caller wants to check before it commits to emitting anything.
function actions.valid(entry)
return builder(entry) ~= nil
end
return actions