Desktop plumbing: the polkit agent, XDG associations, the wallpaper, the cheatsheet
Edit on GitHubSmall modules whose reasoning is longer than their config.
The polkit agent
modules/home/desktop/polkit-agent.nix. What shows the password dialog when a GRAPHICAL app needs
authorization: mounting another user's disk, writing to a block device, controlling a systemd unit
from the GUI.
The gap this closes (01/08/2026): polkitd was running, but there was NO agent at all. The
daemon alone only decides "allowed / not allowed" through the rules; what asks the human for the
password is the agent. Without it, every action requiring auth_admin failed SILENTLY: the app
asked for authorization, nobody answered, and the user only saw "permission denied" with no prompt.
Discovered trying to elevate woeusbgui, but the hole was general.
Why hyprpolkitagent: it is the Hyprland ecosystem's (Qt6/QML), it matches the Qt/Kvantum desktop here, and upstream already ships a ready systemd unit. It is rewritten in Nix so there is ONE OWNER (rule 14) instead of depending on the package's file.
A known trap, graphical-session.target: in many Hyprland+NixOS setups that target stays
INACTIVE and everything depending on it silently does not come up (home-manager#8547). Here it is
active, the same mechanism that raises this repo's autostart-* units, so the default WantedBy
works. If the autostarts ever stop coming up, this agent stops with them, and the symptom will be
"the password prompt disappeared", which does not look like a target problem.
Three details: ConditionEnvironment = WAYLAND_DISPLAY makes it a clean no-op outside a Wayland
session instead of restarting against a display that does not exist; the binary lives in libexec/
and not bin/, so getExe does not find it; and Slice = session.slice makes it die with the
session instead of becoming an orphan.
To check: systemctl --user status hyprpolkitagent.
XDG associations
modules/home/desktop/xdg.nix. Nothing is INSTALLED here (the Zen package comes from the flake, in
modules/nixos/). It only ASSOCIATES: which .desktop opens what.
xdg.mimeApps writes ~/.config/mimeapps.list (managed, read-only), and that is what
xdg-settings get default-web-browser and the GTK/Electron apps consult. Zen's .desktop is
zen-beta.desktop. Check with xdg-mime query default x-scheme-handler/https.
home.sessionVariables.BROWSER closes the case of the terminal apps (git, gh, the xdg-open CLI)
that ignore mimeapps and read the env var instead.
Text and code go to VS Code. Without those lines a double click fell into Okular (the reader back then), which
claims text/plain and markdown in its .desktop, or into NOTHING, since json/csv/yaml/toml/py/js had no
handler at all. One oddity worth keeping: .ts is text/vnd.trolltech.linguist, because
shared-mime-info matches Qt Linguist and not TypeScript.
applications.menu: without it, KDE apps open NOTHING on a double click
KF6 apps (Dolphin, Gwenview, Ark) resolve "who opens this file" through
KApplicationTrader, which reads the ksycoca index. And kbuildsycoca only discovers the
.desktop files by walking the XDG menu (applications.menu), so without that file it indexes
ZERO applications and the double click fails in silence, even with a perfectly fine
mimeapps.list. The journal says "applications.menu not found in …".
Plasma would bring its own (plasma-applications.menu), but here the session is Hyprland, so it
has to be declared. A flat menu (<All/>) because the only consumer is the ksycoca index, not a
launcher with categories: Vicinae reads the .desktop files directly. If Plasma ever comes in, it
uses the XDG_MENU_PREFIX=plasma- prefix and ignores this file, so there is no conflict.
The wallpaper
modules/home/desktop/wallpaper.nix. hyprpaper (Hyprland's official daemon: static, light, declarative),
drawing a random photo per monitor out of ~/Pictures/wallpapers/, with the packaged
nixos-artwork one as the fallback for an empty pool.
One pool per ORIENTATION, wide/ and tall/, and that is not tidiness. hyprpaper covers, so a
16:9 frame on the standing panel keeps 56% of its width and a 9:16 one on the main panel keeps 32%
of its height. Mixing the two pools would mean half the draws landing badly cropped.
The photos are STATE, not config (rule 6), the same call the lock screen's art made: no binary in git, no URL that can rot the build, and adding one is dropping a file. Restic covers the path.
DARK is a measurement here, not a taste. The criterion is ImageMagick's %[fx:mean], the mean
luminance on a 0 to 1 scale, and the bar is roughly 0.15. What shipped measures 0.067 to 0.164
(Milky Way over Chapada dos Veadeiros, Serra da Canastra, Serra Catarinense and others, plus one
Hubble galaxy), while the daylight dune shots that the same search returned came back at 0.30 and
up. identify -format "%f|%w|%h|%[fx:mean]\n" over a folder ranks candidates in one pass, and it is worth rerunning on
anything new that goes in.
One monitor at a time. wallpaper-shuffle takes 1 for the main panel, 2 for the standing
one and nothing for both, which is the numbering my.monitors already uses. The aliases are wp1,
wp2 and wp. Anything else prints the usage and exits 2, so a typo does not silently redraw the
wrong screen.
The boot draws NOTHING when a pin exists. A pool can hold a .pinned symlink; with no
argument (the activation and the unit's ExecStartPre) the script honours it, so the same pair
comes back on every boot. 1, 2 and both always draw, and wallpaper-shuffle pin (alias
wppin) freezes whatever is on screen as the new boot choice. The pin is a DOTFILE on purpose:
the * glob that feeds the draw never sees it, so it cannot be drawn as if it were a photo.
The Hubble set comes from ESA/Hubble, not from images.nasa.gov. The NASA archive serves most
Hubble frames at 1280 px wide, which a 2560 panel would stretch twice over; the ESA/Hubble CDN
(cdn.esahubble.org/archives/images/large/potwAASSa.jpg, CC BY 4.0) has them at full size, and the
Picture of the Week IDs are predictable enough to sweep a year from the 700 px thumbnails first.
The keepers were picked by eye off a contact sheet, since luminance alone let lensing clusters and
near-empty frames through: one galaxy carrying the frame, like the Megamaser that started it.
Each photo can carry a CAPTION, drawn by the shell in the bottom-right corner of its screen
(quickshell/caption/Caption.qml). The facts live next to the image as <name>.json (name of the
object, the ESA title, distance, constellation, credit), and the shuffle copies the chosen photo's
file to ~/.cache/wallpaper/{wide,tall}.json, which the component watches. A photo with no sidecar
writes an EMPTY caption rather than leaving the previous one on screen.
It is NOT burnt into the image, for three reasons: hyprpaper covers, so a bottom-right corner is exactly what a crop can cut; the text would change size with every photo's resolution; and the originals would turn into edited copies. It sits on the BOTTOM layer, over the wallpaper and under every window, reserves nothing and takes no input, so it is visible exactly when the wallpaper is. The credit line is not decoration either: the ESA/Hubble frames are CC BY 4.0, which asks for it.
A distance is left blank when the source has none, instead of filled from memory: ESA leaves some empty, and NGC 5765B (the Megamaser) had no reliable figure in any source checked.
Every branch in that script is an if and never [ x ] && y. writeShellApplication turns on
set -e, and a failing left side of && takes the whole script down. The empty-pool path lands
exactly there, which is the one case that MUST survive: a pool with nothing in it is supposed to
fall back to the packaged image, not abort and leave the screen bare. Tested with the pool emptied:
it falls back and exits 0.
The symlink is read at START, the IPC applies it NOW. The config points at
~/.cache/wallpaper/{wide,tall}, which is what hyprpaper reads when it comes up; wallpaper-shuffle
repoints those and then calls hyprctl hyprpaper preload plus wallpaper with the REAL path,
because hyprpaper caches by path and the link's own path never changes. It runs on activation, as a
non-fatal ExecStartPre on the unit, and by hand whenever a new photo goes in the folder.
It no longer matches the lock screen, on purpose. The old contract was "the same 2 images, so unlocking changes nothing", and it died when the lock started drawing from its own art folder. The two surfaces now have different jobs: the lock is where art gets looked at, the desktop is what sits behind the work, which is why one is picked for the subject and the other for being dark.
The config format (hyprpaper 0.8.x) is why the screen went BLACK. 0.8 swapped the flat format
(wallpaper = MONITOR,path plus preload = plus ipc =) for a CATEGORY
(wallpaper { monitor = …; path = …; }). preload and ipc do not even exist in the binary
anymore: strings hyprpaper | grep -c preload gives 0.
With the old format hyprpaper COMES UP, finds the monitors, and logs "Monitor DP-2 has no target: no wp will be created". No layer surface is created and the background stays black, with no parse error to give away the reason.
home-manager's services.hyprpaper module still generates the OLD format, so the config is written
here through xdg.configFile and the module comes in only with enable, providing the
systemd --user service and the package. If the module ever learns the new format, this goes back
into settings and the file gets shorter.
Why pathOf reads the directory instead of building the string: the FILE name inside the
package does NOT follow a pattern. Most are nix-wallpaper-<attr>.png, the catppuccin ones are
nixos-wallpaper-<attr>.png, and gradient-grey is NixOS-Gradient-grey.png. Building the path by
string would break on a swap, and that is exactly what made the "switching = 1 line" comment a lie.
The options are in
nix eval nixpkgs#nixos-artwork.wallpapers --apply builtins.attrNames, about 30 of them.
The keybind cheatsheet
modules/home/desktop/cheatsheet.nix. A filterable list of ALL the Hyprland binds, on SUPER+H,
shown in vicinae dmenu (rofi until 13/10/2026).
GENERATED from keybinds.lua at RUNTIME, never written by hand: a duplicated list would become
a lie on the first new bind. The file it reads is ~/.config/hypr/lua/keybinds.lua, which is a
mkOutOfStoreSymlink into the repo, so the cheatsheet even follows a hot-reload edit with no
rebuild.
Why SUPER+H and not SUPER+/: Moonlight does NOT send the ABNT2 "/ ?" key (bug #1789, the same
reason as the ScrollLock remap in keybinds.lua), so SUPER+/ would die over remote access. H is
free and gets through on any path.
The awk parser's rules
All dictated by keybinds.lua's real format:
- the GROUP is the first line of the comment block right ABOVE the bind;
- the DESCRIPTION is the comment at the END of the bind's line, falling back to the group's text;
- binds inside
hl.define_submapget a submap prefix, otherwise1/2/Escwould show up loose and meaningless in the list.
Two details that broke earlier versions and are therefore explicit:
- the comment is found by the LAST
" -- "on the line, not by a "no hyphen" regex, because legitimate descriptions contain a hyphen ("no-op", "qs-restart") and were disappearing; - the keys are translated TOKEN BY TOKEN (
commato,), not with a blind gsub, otherwise the "left" inside "mouse_left" would be substituted too.
short() cuts only at the first ". " and never at ": ", since groups like "Mouse: move /
resize the window" would become the useless "Mouse".
The script fails LOUDLY, with a critical notification, in three cases: the symlink is gone, the
parser printed nothing, or the Vicinae server is down. An empty cheatsheet would lie by saying
"there are no binds", and dmenu exits non-zero on Esc too, so a dead server is checked first with
vicinae ping instead of being guessed from the exit code. It only DISPLAYS: the choice is
discarded, since it is a reference and not an action runner. The columns line up because Vicinae's
font is my.fonts.ui, which is monospace.
The clipboard
The clipboard HISTORY is Vicinae's since 13/10/2026 (SUPER+SHIFT+V), which replaced cliphist and
its rofi picker: vicinae.md. What stays here is what Vicinae does not do: putting the
clipboard on another machine. wl-clip-persist also stays, because Vicinae records the history but
does not take over the selection when the app that copied it closes.
clipboard-push: the clipboard onto a remote host
modules/home/desktop/clipboard-push.nix. clipboard-push [host|local] (SUPER+ALT+V; with no argument the destination is a focused VS Code Remote-SSH host, else workstation) sends
what is in the clipboard over scp and leaves the ABSOLUTE REMOTE PATH in the clipboard. With local
it skips the network and drops the file in ~/.cache/clipboard-push instead, which is the half the
kitten below uses when the window is not an ssh. It exists for one
workflow: Claude Code running on the FAI workstation over ssh reads THAT machine's filesystem and
cannot see this one's clipboard, so an image only reaches it as a path.
An image wins, a copied file is the fallback. wl-paste --list-types decides: the first
image/* becomes clip-<timestamp>.<ext>, and failing that a file:// URI (what Dolphin leaves
in the clipboard) goes up under its own basename. The URI is percent-decoded, because a name with a
space arrives as a%20b.png and would land as a file nobody can find.
The remote half goes in through ssh's STDIN, the same trick as wake-workstation
(fai-workstation.md), for the same reason: the workstation is
somebody else's Ubuntu and nothing may be installed on it. That one connection does three things:
it creates ~/.cache/clipboard-push, deletes what is older than a week, and prints $HOME
RESOLVED, which is what scp and the TUI both need, since a ~ pasted into a prompt is not expanded
by whatever reads it. The prune shares the pass on purpose: a drop that only grows, on a machine
that is not mine, is the kind of mess nobody comes back to clean.
wl-copy gets the path with NO trailing newline, and that is the detail the flow lives on: a
newline pasted into a TUI SUBMITS the prompt instead of typing into it.
VS Code Remote-SSH gets the one-key flow from the compositor. Its terminal has no hook that
runs a LOCAL command, so nothing inside VS Code can reach this clipboard; Hyprland knows both the
window and the clipboard. With no argument, clipboard-push reads hyprctl activewindow: a code
window whose title carries [SSH: <host>] (VS Code's default title) becomes the destination, and
after the copy hl.dsp.send_shortcut sends Ctrl+Shift+V (VS Code's terminal paste) to that window's
ADDRESS, captured before the scp, for the same reason the kitten targets its own window. Any other
window keeps the old behavior: push to workstation, no paste.
Ctrl+V does the same in that window, through a Lua bind in keybinds.lua that is
non_consuming: the key ALWAYS reaches the window as before, and the bind only adds work when the
active window is VS Code with [SSH: in its title, a check that runs inside the compositor with no
process spawned. There it runs clipboard-push --image-only, which exits quietly unless the
clipboard holds an image, so text pastes stay VS Code's own and a copied path is never mistaken for
a file to upload. A consuming bind with a send_shortcut passthrough was the alternative and was
rejected: it would route the most used key of every app through the bind, and a mistake there
breaks pasting everywhere. The price of non_consuming is that Claude Code also sees the Ctrl+V and
finds no image on the workstation's clipboard; the path lands a moment later anyway.
It costs one handshake and not two, because the workstation block in modules/home/shell/ssh.nix sets
ControlMaster auto: the scp reuses the master the ssh above opened. And the image is not lost when
the path takes its place in the clipboard, since Vicinae's history still holds it, one SUPER+SHIFT+V
away.
ctrl+shift+v decides, and the kitten is how
modules/home/shell/kitty/smart-paste.py, bound to ctrl+shift+v in modules/home/shell/kitty.nix. Two keys for
one intention was the wrong shape: push with SUPER+ALT+V, then paste, and remember which window was
an ssh and which was not. The terminal already knows, so the kitten asks it.
The divergence is narrow ON PURPOSE. Only an IMAGE in the clipboard takes the new path, and
everything else falls through to kitty's own paste, with the bracketed paste, the paste_actions
filters and the large-paste confirmation that come with it. The most used key on this machine is the
wrong place to reimplement pasting. SUPER+ALT+V is still what pushes a copied FILE, which the kitten
ignores deliberately.
The destination comes from the window's FOREGROUND PROCESS (window.child.foreground_processes,
the same data kitty @ ls prints). An ssh there means the destination is its host, parsed by
skipping the flags that swallow the next argument, where the test is the LAST letter: that is what
tells -p 22, a value follows, from -p22, attached. Anything else means local. What this cannot
see: an ssh started INSIDE the session (a second hop, tmux) and the cesar alias, whose
RemoteCommand is exactly why scp needs the cesar-cmd twin (ssh.md).
It never blocks kitty, and this is the part that is easy to get wrong: handle_result runs IN
the kitty process, so a blocking scp would freeze the whole terminal for as long as the transfer
lasts, and for 14 s with the VPN down (ConnectTimeout 7, two attempts). The child is spawned
instead, and boss.monitor_pid does the paste when it dies. no_ui=True on the handler is what
keeps an overlay window from flashing: with it kitty skips main() and calls the handler in
process. main() still has to EXIST, since the loader reads it unconditionally.
The paste targets the window the key was pressed in, never the active one. After a transfer of a couple of seconds those are not necessarily the same window, and a path pasted into whatever was switched to in the meantime is worse than no paste at all.
ctrl+alt+v stays bound to the plain paste, because a kitten that breaks takes ctrl+shift+v down with it, and rule 15's point about an automation with no fallback holds for a keystroke too.
MEASURED on 14/09/2026, driving a throwaway kitty over its own remote control: text pasted
unchanged; an image in a window running ssh workstation arrived on the workstation and pasted as
/home/v1cferr/.cache/clipboard-push/clip-<stamp>.png; the same image in a local shell landed in
~/.cache and pasted the local path. A Wayland trap showed up in the harness and not in the
feature: an UNFOCUSED window gets no selection offer, so get_clipboard_string() there is empty and
even kitty's own paste_from_clipboard pastes nothing.
The launcher on SUPER+Q is Vicinae: vicinae.md.