-- ───────────────────────────────────────────────────────────────────────────── -- 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 = "", label = "" } -- -- `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