Omahub
← All plugins
W

Key Swap

by waqas

Per-keyboard Alt/Super swapping, configured from Setup > Key Swap in the Omarchy menu.

Security review

No obvious issues detected

Deterministic scan — not a security guarantee

None
Risk level
None
Analyzed commit
0270ab7
Scanned
1 month ago

No potentially dangerous behavior detected in the analyzed commit.

Automated analysis only — not a security guarantee.

AI advisory review

No obvious issues detected

Language-model assessment · ~deepseek/deepseek-v4-flash-latest — advisory only

Low
AI risk level
Low
Recommendation
install
Model
~deepseek/deepseek-v4-flash-latest
Analyzed commit
0270ab7
Reviewed
1 month ago

The plugin is a bash script that manages per-keyboard XKB options via Hyprland. It demonstrates strong security practices: strict input validation for device names and options, safe atomic file writes, and structural JSON generation. The deterministic scan found no issues, and the code appears well-defended against injection and symlink attacks.

  • The plugin uses `hyprctl eval` to apply Lua configuration, which could be risky if input validation were bypassed, but the validation regexes are strict and appear to prevent injection.
  • The script writes to user configuration and state files, but it does so carefully with symlink checks and atomic renames.
How this check works

This review combines the deterministic scan (the rule-based results above) with an independent look at the plugin's code by a language model. The model reads a trimmed sample of the repository's files, the manifest, and the README, then gives a plain-language risk level and a recommendation: install (no notable danger), review (look closer first), or avoid (clearly dangerous).

It runs on the same analyzed commit as the deterministic scan and is strictly advisory — it is not a security guarantee and never blocks a plugin by itself. A human moderator still reviews plugins before they are listed.

AI advisory only — automated analysis, not a security guarantee.

Install
$ omarchy plugin add https://github.com/wqsaali/omarchy-key-swap --enable
Hardware #Hyprland #quickshell #system

Key Swap

Per-keyboard modifier swapping for Omarchy — swap Alt and Super/Windows on one keyboard without touching the others.

Configured entirely from the Omarchy menu:

SUPER+Space → Setup → Key Swap → keyboard → mode

The active mode carries a ✓. Changes apply immediately and survive reboot.

Setup → Key Swap → keyboard → mode

Setup Keyboards Modes
Key Swap in the Setup menu The connected keyboards Modes, with the active one ticked
The row appears only while the plugin is enabled One entry per connected keyboard The active mode carries a ✓

Nothing is written into ~/.config/hypr — the plugin owns its own persistence, so uninstalling is deleting the plugin.

Install

omarchy plugin add git@github.com:wqsaali/omarchy-key-swap.git --enable

Then generate the menu rows once:

~/.config/omarchy/plugins/waqas.keyswap/bin/keyswap menu

Dependencies

  • jq
  • hyprctl with the Lua config parser (Omarchy Quattro)
  • bash

All of these ship with Omarchy; nothing is downloaded at install or run time.

The menu rows have to live in ~/.config/omarchy/extensions/omarchy-menu.jsonc because the shell reads exactly one user extension file and provider names cannot be declared from JSONC. They go in a marked block that nothing else in that file is disturbed by, and keyswap menu --remove takes them out cleanly.

Modes

Mode Effect
Standard No swap (inherits the global setting)
Alt ↔ Super Swap both the left and right pairs
Left pair Swap Left Alt with Left Super only
Right pair Swap Right Alt with Right Super only
Ctrl → Alt → Super Rotate Ctrl into Alt, Alt into Super
Alt sends Super Alt acts as Super as well as Alt
Menu → Super Map the Menu key to Super

Labels are deliberately short. The menu ellipsizes a row that overflows its width, and the ✓ is appended to the label — so a long label loses its own checkmark. Detail belongs in description, which renders as a subtitle.

The Fn key is not supported

Fn is handled in keyboard firmware or the laptop's embedded controller and usually never emits a keycode the OS can see, so no compositor-level tool can remap it — Windows/PowerToys can't either. Change it in BIOS setup (Fn Lock / Function Key Behavior), or reflash the keyboard with QMK/VIA. For remapping that XKB can't express at all (layers, tap-hold, arbitrary key→key), use keyd instead.

How it works

Swaps are XKB altwin: options applied per device, so they take effect below Hyprland and below applications — Alt+Tab in a browser, Alt+F4, and readline's Alt+b/Alt+f all follow the swap, which a rebound keybinding could not achieve.

  • Applied live with hyprctl eval (this Hyprland runs the Lua config parser, so hyprctl keyword is unavailable for device blocks).
  • Persisted by the plugin, not by your config. Nothing is written into ~/.config/hypr. The service reapplies the saved swaps whenever they would otherwise be lost:
    • at shell startup,
    • on Hyprland's configreloaded (a reload rebuilds the running config from files that do not carry device rules, so every reload drops the swaps),
    • when the set of connected keyboards changes.
  • State is runtime state, not configuration: ~/.local/state/omarchy/keyswap.json, alongside the other generated files Omarchy keeps there. A state file left at the old ~/.config/omarchy/keyswap.json path is migrated on first read.

The trade-off of owning persistence in the plugin rather than in Hyprland's config: swaps apply once the shell is up rather than the instant Hyprland parses its config, so there is a brief window at login where the keyboard is unswapped. In exchange, uninstalling is deleting the plugin -- there is no config edit to undo, and nothing left behind to confuse a future omarchy update.

Per-device kb_options replace the global value rather than extending it, so Omarchy's own options (compose:caps, shift:both_capslock_cancel) are carried across on every write. Omitting them would silently disable Caps-as-compose.

One physical keyboard often registers as several Hyprland devices (kbd and kbd-1); a swap is applied to all of them, or half the keypresses arrive unswapped. Only the primary is listed in the menu; siblings follow along.

Plugin shape

Registered as a service plugin: it draws nothing and takes no bar slot. The service exists to keep things honest over time —

  • at startup it runs keyswap sync, so a keyboard plugged in while the shell was down still gets its saved rule applied;
  • every 15s it runs keyswap menu --if-needed, which rewrites the menu rows only when the set of connected keyboards actually changed. Hyprland raises no event when an input device arrives or leaves (the first-party keyboard-layout widget polls for the same reason), so this has to be asked for.

--if-needed compares what it would write against what is already in the file, so the timer never touches the extension file for nothing, and a block deleted by hand repairs itself on the next tick.

Menu rows

The rows live in a marked block inside ~/.config/omarchy/extensions/omarchy-menu.jsonc, because the Omarchy menu reads exactly one user extension file and it cannot be a file of ours. Only that block is rewritten; anything else in the file is preserved.

bin/keyswap menu             # (re)generate the rows -- run after plugging in a keyboard
bin/keyswap menu --remove    # take them out again

Every generated row carries "when": "keyswap enabled", not just the Setup > Key Swap parent. Search matches rows on their own when rather than their ancestors', so guarding only the parent hides it from Setup navigation while leaving every device and mode row findable by typing "key" — which is exactly the wrong behaviour. Guard all of them or none.

enabled reads the plugins array in shell.json, which is what omarchy plugin enable/disable writes. It is pure bash with no subprocess, because it runs once per generated row on every menu open. An unreadable file leaves the rows hidden, which is the safe direction.

The guards are evaluated asynchronously after the menu opens, so a disabled plugin's rows can be briefly visible in search before the batch lands.

The ✓ marks come from checked shell conditions the menu re-evaluates on every open (asynchronously, then it rebuilds the display), so the rows never need regenerating just because a mode changed — only when the set of connected keyboards does.

CLI

The menu is a front end to a helper that works standalone:

bin/keyswap list                                  # keyboards + current mode (JSON)
bin/keyswap modes                                 # available modes (JSON)
bin/keyswap set royuan-gaming-keyboard swap_alt_win
bin/keyswap clear royuan-gaming-keyboard
bin/keyswap sync                                  # reapply after replugging
bin/keyswap menu [--remove]                       # regenerate/remove the menu rows

Uninstall

bin/keyswap menu --remove          # drop the menu rows
omarchy plugin disable waqas.keyswap
rm -rf ~/.config/omarchy/plugins/waqas.keyswap
rm -f ~/.local/state/omarchy/keyswap.json ~/.local/state/omarchy/keyswap-devices

Swaps already applied stay live until the next Hyprland config reload, which drops them back to the global setting. Nothing in ~/.config/hypr was ever touched, so there is nothing to revert there.

Handling of device names

Device names come from libinput by way of hyprctl, which means a USB device chooses its own name. They are treated as untrusted input, because they would otherwise reach two contexts where syntax matters: the Lua string handed to hyprctl eval, and the shell conditions and actions written into the menu.

  • Names are validated at a single choke point. Everything entering the program must match ^[A-Za-z0-9][A-Za-z0-9._:+-]*$ and be at most 128 characters — no quotes, backslashes, $, backticks, whitespace, or control characters. A device whose name fails is dropped with a warning and never reaches any later stage. Names are read base64-encoded so one containing a newline cannot split into two shorter names that each look legitimate.
  • The menu never carries a device name. Generated rows address keyboards by a slug derived from the name by replacing every non-alphanumeric run with a dash, so a slug can only be [A-Za-z0-9-]. The generated checked and action strings interpolate only that slug, a mode literal from a fixed table, and the plugin's own path. get-slug/set-slug resolve the slug back against the devices actually connected.
  • Menu rows are encoded structurally. Every id and value is produced by jq, not by string concatenation, so encoding is done by a JSON encoder.
  • The hyprctl eval payload is re-checked at the point of use. Both the device name and the composed kb_options are validated again immediately before the Lua string is built, rather than trusted from upstream.
  • Device names are never used as patterns. Sibling matching compares strings rather than building a regex from a name.

How files are written

Every write goes through one helper rather than a shell redirection.

> path is wrong here in two ways. It opens through a symlink, so another local process able to write into these predictable state directories could point one of them at any file this user owns and have the plugin overwrite it. And it truncates the destination before the replacement exists, so an interrupted write loses the previous contents.

Instead: the destination and its directory are rejected if either is a symlink, the replacement is written to a temporary file created in the destination's own directory with mode 600, and moved into place. Keeping the temporary beside the target matters -- one in /tmp is usually on a different filesystem, where the move degrades into a copy.

Files this plugin writes

Path What
~/.local/state/omarchy/keyswap.json Saved mode per keyboard
~/.local/state/omarchy/keyswap-devices Last-seen keyboard set, for change detection
~/.config/omarchy/extensions/omarchy-menu.jsonc The menu rows, inside a marked block

The extension file is the only one of these that is user-authored. Writes to it are confined to the block between the >>> waqas.keyswap and <<< waqas.keyswap markers, are diffed before writing so an unchanged block is left alone, and keyswap menu --remove removes the block and nothing else. Everything outside the markers -- your own rows, the comments Omarchy ships -- is preserved verbatim.

Nothing is ever written into ~/.config/hypr.

License

MIT -- see LICENSE.