Skip to main content

max / alloy

Make the brightness keys work and let Print Screen say so Two silent failures found on the one running install, 2026-07-29. Brightness. SwayOSD sets brightness by writing /sys/class/backlight/<dev>/brightness, which ships root-owned 0644. The package ships the udev rule that fixes it, at /usr/lib64/udev/rules.d/99-swayosd.rules, which udev does not read: the same packaging defect as the libinput unit above it in the Containerfile, different subsystem. The rule is copied onto udev's search path, asserted first so a Fedora fix fails the build instead of installing a stale duplicate. The rule chgrp's to `video`, and a group with no members grants nothing, so the installer's useradd puts the account in it too. Either half alone leaves the keys dead, so both are pinned by tests. Print Screen. The four binds were inline grim calls that captured correctly and told nobody, which from the keyboard is the same as a dead key. They now go through usr/bin/alloy-shot, which captures and raises a notification. One script rather than `&& notify-send` appended to each bind, because the old `set $shot` holds a literal $(date ...) that a second expansion would evaluate again, naming a file that does not exist whenever a capture straddles a second boundary, and because slurp exits non-zero when a region select is cancelled with Escape, which would have announced a failure for a deliberate cancel. Active window is bound for the first time; jq is a declared dependency, so the old deferral cost nothing but the bind. libnotify is now declared. mako implements the server half and does not pull the client in, so notify-send had been arriving as somebody else's transitive dependency. The build reads the bound modes back out of the shipped sway config and asks the script for each, so a renamed mode fails the build rather than a keypress.
Author: Max Johnson <me@maxj.phd> · 2026-07-30 02:31 UTC
Signed with PGP, not checked
Commit: 7f6c8dce5673453ac332d953f7ba698a08e2b9ef
Parent: 758686a
5 files changed, +251 insertions, -14 deletions
@@ -333,6 +333,13 @@
333 333 xdg-desktop-portal xdg-desktop-portal-gtk xdg-desktop-portal-wlr \
334 334 # Notifications, screenshot annotate, wallpaper (bar = sway's built-in swaybar)
335 335 mako \
336 + # notify-send, which is the *client* half. mako implements the
337 + # notification server and does not pull this in, so it has been arriving
338 + # as somebody else's transitive dependency. usr/bin/alloy-shot calls it on
339 + # every screenshot, and the failure mode if it goes missing is the exact
340 + # one that script was written to fix: a capture that happens and says
341 + # nothing. Same reasoning as jq below.
342 + libnotify \
336 343 satty \
337 344 swww \
338 345 # Terminal, editor, shell, prompt
@@ -562,6 +569,51 @@
562 569 && cp /usr/lib64/systemd/system/swayosd-libinput-backend.service \
563 570 /usr/lib/systemd/system/swayosd-libinput-backend.service
564 571
572 + # The same packaging bug again, in udev this time, and it is why the brightness
573 + # keys do nothing.
574 + #
575 + # Found on real hardware 2026-07-29: the volume Fn keys show an OSD and the
576 + # brightness ones do nothing at all, silently. SwayOSD raises brightness by
577 + # writing /sys/class/backlight/<dev>/brightness directly, which ships
578 + # root-owned 0644. The package knows this and ships the rule that fixes it:
579 + #
580 + # ACTION=="add", SUBSYSTEM=="backlight", RUN+="/bin/chgrp video /sys/class/backlight/%k/brightness"
581 + # ACTION=="add", SUBSYSTEM=="backlight", RUN+="/bin/chmod g+w /sys/class/backlight/%k/brightness"
582 + #
583 + # It ships it at /usr/lib64/udev/rules.d/99-swayosd.rules. udev reads
584 + # /usr/lib/udev/rules.d, /run/udev/rules.d and /etc/udev/rules.d, and on this
585 + # base /usr/lib64/udev is a real directory rather than a symlink, so the rule
586 + # never fires and the permissions never change. Same defect as the unit above,
587 + # same cause, different subsystem.
588 + #
589 + # Why it failed silently rather than logging: swayosd-server is started by
590 + # `exec swayosd-server` from the sway config, so its stderr goes nowhere a
591 + # journal can see it. A permission error on the sysfs write has no reader.
592 + #
593 + # Copied rather than symlinked, and asserted first, for the same reasons as the
594 + # unit: the copy survives the package moving the file, and the day Fedora fixes
595 + # the path this build fails loudly instead of installing a stale duplicate that
596 + # quietly disagrees with the packaged one.
597 + #
598 + # This is half the fix. The rule chgrp's to `video`, and a group with no members
599 + # grants nothing, so the installer puts the account in it — see the `useradd`
600 + # stage in crates/alloy/src/install.rs. Either half alone leaves brightness
601 + # broken, which is why the group membership is asserted further down rather than
602 + # left to be discovered on a booted machine.
603 + RUN set -eux; \
604 + packaged=/usr/lib64/udev/rules.d/99-swayosd.rules; \
605 + canon=/usr/lib/udev/rules.d/99-swayosd.rules; \
606 + [ -f "$packaged" ] \
607 + || { echo "SwayOSD no longer ships $packaged — re-check where the backlight rule went" >&2; exit 1; }; \
608 + [ ! -f "$canon" ] \
609 + || { echo "SwayOSD now ships the udev rule on udev's search path — drop this workaround" >&2; exit 1; }; \
610 + grep -q 'SUBSYSTEM=="backlight"' "$packaged" \
611 + || { echo "$packaged no longer matches the backlight subsystem; copying it would fix nothing" >&2; exit 1; }; \
612 + grep -q 'chgrp video' "$packaged" \
613 + || { echo "$packaged no longer chgrp's to video; the installer's group grant is now the wrong group" >&2; exit 1; }; \
614 + mkdir -p /usr/lib/udev/rules.d; \
615 + cp "$packaged" "$canon"
616 +
565 617 # distrobox is pinned. v2 is a Go rewrite, at rc.3 as of 2026-06-29, and
566 618 # upstream's own announcement says v1 stays the production recommendation
567 619 # and that exported binaries and apps must be re-exported after
@@ -700,6 +752,40 @@
700 752 RUN grep -q -- '--cmd alloy-session' /etc/greetd/config.toml \
701 753 || { echo "greetd does not launch the session wrapper; the skeleton would never be applied" >&2; exit 1; }
702 754
755 + # =====================================================================
756 + # The screenshot helper — assert the binds reach it.
757 + #
758 + # Not the boot path, so a lighter case than alloy-session above, but the
759 + # same silence: the four Print binds `exec alloy-shot <mode>`, and sway's
760 + # exec reports a command it cannot run to its own log and nowhere the
761 + # person pressing the key will see. Missing, non-executable, or a mode
762 + # named in the config that the script does not answer to, and the keys go
763 + # back to doing nothing, which is the state this whole helper exists to
764 + # end.
765 + #
766 + # The modes are read back out of the config's binds and asked of the
767 + # script, so the two cannot drift: a renamed mode fails here rather than
768 + # on a keypress. Every dependency the script shells out to is checked in
769 + # the same pass, since each one is a bind that silently stops working.
770 + # =====================================================================
771 + RUN set -eux; \
772 + test -x /usr/bin/alloy-shot \
773 + || { echo "alloy-shot is missing or not executable; every Print bind would do nothing" >&2; exit 1; }; \
774 + sh -n /usr/bin/alloy-shot \
775 + || { echo "alloy-shot does not parse; every Print bind would do nothing" >&2; exit 1; }; \
776 + for tool in grim slurp jq swaymsg satty notify-send; do \
777 + command -v "$tool" >/dev/null \
778 + || { echo "alloy-shot needs $tool and it is not in the image" >&2; exit 1; }; \
779 + done; \
780 + modes=$(sed -n 's/^bindsym [^ ]*Print *exec alloy-shot \([a-z]*\).*/\1/p' /etc/skel/.config/sway/config); \
781 + [ -n "$modes" ] \
782 + || { echo "no Print bind in the shipped sway config calls alloy-shot" >&2; exit 1; }; \
783 + for mode in $modes; do \
784 + grep -q "^ $mode)" /usr/bin/alloy-shot \
785 + || { echo "the sway config binds alloy-shot $mode, which the script does not handle" >&2; exit 1; }; \
786 + done; \
787 + echo "alloy-shot: $(echo "$modes" | wc -l) bound modes, all handled"
788 +
703 789 # =====================================================================
704 790 # polkit rules — assert the grant is not inert.
705 791 #
M docs/STACK.md +8 -2
@@ -54,10 +54,12 @@
54 54
55 55 ## Screenshot stack
56 56
57 - - **grim** for capture, **slurp** for region select. sway has no built-in screenshot (Niri did, which is why this stack changed in the pivot); grim+slurp is the canonical wlroots pairing. Bound in the sway config: Print (full), Shift+Print (region via slurp), Ctrl+Print (active window). The active-window grab reads the focused rect out of `swaymsg -t get_tree` with jq, which the fedora-bootc base already carries, so it costs nothing to ship.
58 - - **satty** for annotation when needed (Rust, modern, replaces swappy). Mod+Print annotates the most recent capture.
57 + - **grim** for capture, **slurp** for region select. sway has no built-in screenshot (Niri did, which is why this stack changed in the pivot); grim+slurp is the canonical wlroots pairing. The active-window grab reads the focused rect out of `swaymsg -t get_tree` with jq, a declared dependency of the image.
58 + - **satty** for annotation when needed (Rust, modern, replaces swappy).
59 59 - **wl-clipboard** for clipboard plumbing.
60 60
61 + The four binds go through [`alloy-shot`](../usr/bin/alloy-shot) rather than calling grim directly: Print (full output), Shift+Print (region via slurp), Ctrl+Print (active window), Mod+Print (annotate the most recent capture in satty). **A capture raises a notification**, which is the reason that script exists. The binds were four inline grim calls until 2026-07-29, when Print Screen was reported as a dead keybind on the one running install: it had been capturing correctly the whole time and saying nothing, and from the keyboard those are the same thing. Passing through one script also means the timestamp in the filename is expanded once rather than once per command, and cancelling a region select with Escape is not reported as an error.
62 +
61 63 Rejected: swappy (dated annotator; satty replaces it), wayshot (grim is the more standard wlroots grabber).
62 64
63 65 ## File manager
@@ -298,6 +300,10 @@
298 300
299 301 **Sway integration:** Fn keys bound to `swayosd-client --output-volume raise` and similar in the sway config (see the config for the block).
300 302
303 + **Volume and brightness do not travel the same path, and brightness needs two grants to work.** Volume goes through PipeWire, a userspace daemon, so nothing privileged is involved. Brightness is a direct write to `/sys/class/backlight/<dev>/brightness`, which ships root-owned `0644`. SwayOSD packages the udev rule that fixes it (chgrp to `video`, add group write) and installs it into `/usr/lib64/udev/rules.d/`, which udev does not read, so on Fedora the rule never fires. The Containerfile copies it onto udev's search path, the same shim and for the same reason as the libinput unit above, and the installer puts the account in `video` — a rule that chgrp's to an empty group grants nothing. Either half alone leaves the brightness keys dead.
304 +
305 + That was the state on the first real install, found 2026-07-29: volume keys showed an OSD and brightness keys did nothing, with no error anywhere. `swayosd-server` is started by the sway config's `exec`, so its stderr has no reader and the failed sysfs write was never logged.
306 +
301 307 Rejected: avizo (Python, less maintained), custom mako notifications for the OSD (mako is for notifications, not indicator overlays; different job).
302 308
303 309 ### Media keys: **playerctl**
@@ -786,11 +786,28 @@
786 786 .arg("--force"),
787 787 ),
788 788 // useradd rather than a sysusers entry because this account needs a home
789 - // and a supplementary group. wheel is the group Fedora's polkit resolves
789 + // and supplementary groups. wheel is the group Fedora's polkit resolves
790 790 // an administrator to, which is what `run0` and every writing console
791 791 // view ask for, and an install whose only account cannot escalate is one
792 792 // with no way to administer itself. See wiki note `alloy-privilege`.
793 793 //
794 + // video is what makes the brightness keys work, and it is not
795 + // interchangeable with wheel. SwayOSD sets brightness by writing
796 + // /sys/class/backlight/<dev>/brightness, which is root-owned 0644 until
797 + // a udev rule chgrp's it to `video` and adds group write; the
798 + // Containerfile puts that rule on udev's search path, since Fedora ships
799 + // it somewhere udev does not read. Both halves are required: the rule
800 + // with an empty group grants nothing, and the group with no rule has
801 + // nothing to grant. Found the hard way on 2026-07-29, when the only
802 + // running install had a working volume OSD and dead brightness keys.
803 + //
804 + // Not `wheel` doing this job instead: the sysfs write is the *only*
805 + // privilege being handed out here, it is one file per backlight device,
806 + // and `video` is the group every distribution already uses for it.
807 + // Widening wheel's meaning to cover a hardware attribute would put
808 + // brightness control behind administrator rights on any future account
809 + // that deliberately has none.
810 + //
794 811 // --no-create-home despite the account needing one: --create-home
795 812 // resolves the path inside the target, which puts the directory in the
796 813 // deployment's own var, where nothing will ever look for it. useradd
@@ -808,7 +825,7 @@
808 825 .arg("--no-create-home")
809 826 .args(["--home-dir", &installed_home])
810 827 .args(["--shell", LOGIN_SHELL])
811 - .args(["--groups", "wheel"])
828 + .args(["--groups", "wheel,video"])
812 829 .arg(username),
813 830 ),
814 831 // -p because /var/home does not exist yet either.
@@ -2892,6 +2909,38 @@
2892 2909 assert!(useradd.ends_with("max"), "{useradd}");
2893 2910 }
2894 2911
2912 + // The other half of the brightness fix. SwayOSD writes
2913 + // /sys/class/backlight/<dev>/brightness, and the udev rule the Containerfile
2914 + // puts on udev's search path chgrp's that file to `video` — a group with no
2915 + // members grants nothing, so an account left out of it has dead brightness
2916 + // keys and no error anywhere. The failure is silent on both sides, which is
2917 + // why it is pinned here rather than trusted to the comment above it.
2918 + #[test]
2919 + fn the_account_can_set_the_backlight() {
2920 + let useradd = command_starting("useradd");
2921 +
2922 + assert!(
2923 + useradd.contains("video"),
2924 + "without `video` the brightness keys do nothing: {useradd}",
2925 + );
2926 + }
2927 +
2928 + // Both groups arrive in one --groups, because a second --groups silently
2929 + // replaces the first rather than adding to it. A refactor that gave each
2930 + // group its own flag would drop wheel and cost the account its ability to
2931 + // administer the machine, with nothing failing until someone tried.
2932 + #[test]
2933 + fn the_groups_are_one_flag_not_two() {
2934 + let useradd = command_starting("useradd");
2935 +
2936 + assert_eq!(
2937 + useradd.matches("--groups").count(),
2938 + 1,
2939 + "a second --groups would discard the first: {useradd}",
2940 + );
2941 + assert!(useradd.contains("--groups wheel,video"), "{useradd}");
2942 + }
2943 +
2895 2944 // --create-home resolves its path against --root, which is the deployment,
2896 2945 // whose var is an empty directory the stateroot's var is mounted over at
2897 2946 // boot. A home made there is a home the installed system never sees.
@@ -210,18 +210,23 @@
210 210 # -------------------------------------------------------------------
211 211 # Binds — screenshots (grim; sway has no built-in capture)
212 212 # -------------------------------------------------------------------
213 - set $shot ~/Pictures/Screenshots/screenshot-$(date +%Y%m%d-%H%M%S).png
213 + # All four go through alloy-shot, which captures and then says so. They were
214 + # four inline grim calls until 2026-07-29, when Print Screen was reported as a
215 + # dead keybind: it worked the whole time and told nobody, which is the same
216 + # thing from the keyboard. The script's header has the rest, including why a
217 + # `&& notify-send` appended here would have named the wrong file.
218 + #
219 + # Active window was deferred once on the belief that jq had to be installed for
220 + # it; jq is a declared dependency of the image, so the deferral cost nothing but
221 + # the bind. Ctrl+Print was a second copy of the region-select line until then.
214 222 # Whole output.
215 - bindsym Print exec grim $shot
216 - # Region select.
217 - bindsym Shift+Print exec grim -g "$(slurp)" $shot
218 - # Active window. This was deferred on the belief that jq had to be
219 - # installed for it; jq is in the fedora-bootc base already, so the
220 - # deferral cost nothing but the bind. Ctrl+Print was a second copy of
221 - # the region-select line until now.
222 - bindsym Ctrl+Print exec grim -g "$(swaymsg -t get_tree | jq -r '.. | select(.focused?) | .rect | "\(.x),\(.y) \(.width)x\(.height)"')" $shot
223 + bindsym Print exec alloy-shot output
224 + # Region select. Escape cancels without an error.
225 + bindsym Shift+Print exec alloy-shot region
226 + # Active window.
227 + bindsym Ctrl+Print exec alloy-shot window
223 228 # Annotate the most recent screenshot with satty.
224 - bindsym $mod+Print exec sh -c 'satty --filename "$(ls -t ~/Pictures/Screenshots/*.png | head -1)"'
229 + bindsym $mod+Print exec alloy-shot annotate
225 230
226 231 # -------------------------------------------------------------------
227 232 # Binds — volume / brightness / media (swayosd + playerctl)
@@ -1,0 +1,91 @@
1 + #!/bin/sh
2 + # alloy-shot — take a screenshot and say that it worked.
3 + #
4 + # The four Print binds in the sway config used to be four inline grim calls.
5 + # They worked, and they were reported as broken anyway, on 2026-07-29, by the
6 + # person who wrote them: pressing Print produced a correct capture and no
7 + # notification, no OSD, no shutter, no window. Nothing at all. The only way to
8 + # learn it had worked was to go and look in the directory, which is
9 + # indistinguishable from a dead keybind. So this exists for the notification
10 + # more than for the capture.
11 + #
12 + # Three things moved in here that were wrong or fragile inline, and are the
13 + # reason this is a script rather than `&& notify-send` appended four times:
14 + #
15 + # 1. The timestamp is taken ONCE. The config's `set $shot` held a literal
16 + # `$(date ...)`, expanded by the shell sway's `exec` runs it through. An
17 + # appended notification naming `$shot` would have expanded it a second time,
18 + # so any capture that straddled a second boundary would have announced a
19 + # filename that does not exist. A notification that lies about the path is
20 + # worse than no notification, and it would have been intermittent.
21 + # 2. Cancelling a region select is not a failure. `slurp` exits non-zero when
22 + # Escape is pressed, which inline made `grim -g ""` run and fail; with a
23 + # notification appended it would have reported an error for a deliberate
24 + # cancel. Here it exits quietly.
25 + # 3. One notification, one wording, one place to change it.
26 + #
27 + # Modes rather than four scripts because the notification and the timestamp are
28 + # the shared part, which is exactly what was missing.
29 +
30 + set -eu
31 +
32 + # XDG_PICTURES_DIR is what xdg-user-dirs writes and what a localized install
33 + # actually uses; ~/Pictures is the fallback for a session that has no user-dirs
34 + # file yet. The sway config also `mkdir -p`s this at startup, and it is repeated
35 + # here because this script is reachable without that having run: from a bind on
36 + # a hand-edited config, from a shell, or on a first login where the exec order
37 + # is not something to depend on.
38 + dir="${XDG_PICTURES_DIR:-$HOME/Pictures}/Screenshots"
39 + mkdir -p "$dir"
40 +
41 + shot="$dir/screenshot-$(date +%Y%m%d-%H%M%S).png"
42 +
43 + # Told, not guessed: a bind that names a mode this does not know is a typo in
44 + # the config, and it should say so rather than silently capture the whole
45 + # screen.
46 + mode="${1:-}"
47 + case "$mode" in
48 + output)
49 + grim "$shot"
50 + ;;
51 + region)
52 + # `|| exit 0` covers the Escape case above. It also swallows a real
53 + # slurp failure, which is the accepted cost: from here the two are the
54 + # same non-zero exit, and treating a cancel as an error is the louder
55 + # mistake.
56 + geometry="$(slurp)" || exit 0
57 + grim -g "$geometry" "$shot"
58 + ;;
59 + window)
60 + # The focused node's rect, in grim's `x,y WxH` geometry. jq is a stated
61 + # dependency of the image for exactly this line; see the Containerfile's
62 + # package list.
63 + geometry="$(swaymsg -t get_tree \
64 + | jq -r '.. | select(.focused?) | .rect | "\(.x),\(.y) \(.width)x\(.height)"')"
65 + grim -g "$geometry" "$shot"
66 + ;;
67 + annotate)
68 + # Annotate the most recent capture rather than taking a new one, which
69 + # is the bind's whole point: shoot first, mark it up after. Nothing to
70 + # notify about, because satty opens a window and is its own feedback.
71 + #
72 + # `ls -t` on a glob that matches nothing exits non-zero under `set -e`,
73 + # so an empty directory ends here instead of running satty on an empty
74 + # filename.
75 + latest="$(ls -t "$dir"/*.png 2>/dev/null | head -1)" || exit 0
76 + [ -n "$latest" ] || exit 0
77 + exec satty --filename "$latest"
78 + ;;
79 + *)
80 + echo "alloy-shot: expected one of output, region, window, annotate" >&2
81 + exit 2
82 + ;;
83 + esac
84 +
85 + # Past here the capture is on disk. The notification is the point of this
86 + # script, so a notification daemon that is not running is worth one line on
87 + # stderr rather than a failed exit: the screenshot was taken either way, and
88 + # `set -e` would otherwise turn a missing daemon into what looks like a failed
89 + # capture.
90 + notify-send -a alloy -i camera-photo "Screenshot saved" "$shot" \
91 + || echo "alloy-shot: saved $shot, but the notification could not be sent" >&2