Skip to main content

max / alloy

Warn before locking, and do not lock over a video The sway config claimed "dim to lock at 5 minutes" for as long as it existed and nothing dimmed: full brightness straight to swaylock at 300 seconds, so the lock could only ever arrive as an ambush and the only way to prevent one was to have been touching the keyboard when it fired. usr/bin/alloy-dim and a 270-second swayidle stage are the thirty seconds of warning, and the stage's own `resume` puts the brightness back, including after an unlock, since typing a password is input. It is also the nearest thing to a grace period available: upstream swaylock has no --grace and taking the swaylock-effects fork for one flag is a poor trade. sysfs rather than a brightness CLI because the image ships none. This is the mechanism the Fn keys already use: SwayOSD writes /sys/class/backlight/<dev>/brightness, a udev rule makes it group-writable, and the installer puts the account in `video`. Three behaviours the script is deliberate about. A second `down` is a no-op, because otherwise it would save the dimmed value as the level to restore and the screen would never come back up. The dim floor is 1 and never 0, since a dark panel is indistinguishable from the display power-off at ten minutes. And a machine with no backlight exits 0 rather than reporting every five minutes that it cannot dim a screen it does not have. The verb is checked before the device is looked for, so a typo is loud even on a machine with no panel; written the other way round, which is how it was first written, a misspelled verb exits 0 on a desktop. The idle inhibitor covers video. sway implements the Wayland idle-inhibit protocol and swayidle honours it, so an app that registers an inhibitor needs no help; these rules cover mpv, which only inhibits while stop-screensaver is on, and a browser that loses its inhibitor mid-session without saying so. Three app_ids and not [app_id=".*"] on purpose: a blanket rule lets any fullscreen window hold the lock off, and on a distro whose primary surface is a terminal that means walking away from a fullscreen shell leaves the machine unlocked indefinitely. The Containerfile asserts the script is present, parses, handles every verb the config calls, and that the `video` group and the 270 stage both still exist. A regression here restores the old behaviour rather than causing a new one, which is the kind that survives unnoticed.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 12:33 UTC
Signed with PGP, not checked
Commit: ccf46635358bc0aa24608c6c5ef9fb6102c21c0b
Parent: fb636cb
3 files changed, +170 insertions, -1 deletion
@@ -812,6 +812,45 @@
812 812 done; \
813 813 echo "alloy-shot: $(echo "$modes" | wc -l) bound modes, all handled"
814 814
815 + # =====================================================================
816 + # alloy-dim — the lock warning, and the two ways it goes quiet.
817 + #
818 + # swayidle calls this at 270 seconds and again on resume. Neither call is
819 + # visible: if the script is missing or unparseable, swayidle logs to its own
820 + # output and the observable result is the old behaviour, full brightness
821 + # straight to a lock, which is exactly the ambush the stage was added to
822 + # remove. A regression here restores a bug rather than causing a new one,
823 + # which is the kind that survives.
824 + #
825 + # The subcommands are read back out of the shipped sway config and asked of
826 + # the script, the same way alloy-shot's modes are, so a renamed one fails
827 + # here instead of on the idle chain.
828 + #
829 + # The `video` group is checked too, because it is the whole mechanism: the
830 + # script writes /sys/class/backlight/<dev>/brightness, which is writable only
831 + # through the udev rule copied above, and the installer puts the account in
832 + # `video` (crates/alloy/src/install.rs). A missing group makes the script exit
833 + # 0 on every call, having found no writable device — dimming would be absent
834 + # and silent, indistinguishable from it never having been added.
835 + # =====================================================================
836 + RUN set -eux; \
837 + test -x /usr/bin/alloy-dim \
838 + || { echo "alloy-dim is missing or not executable; the lock would arrive with no warning" >&2; exit 1; }; \
839 + sh -n /usr/bin/alloy-dim \
840 + || { echo "alloy-dim does not parse; the lock would arrive with no warning" >&2; exit 1; }; \
841 + verbs=$(sed -n "s/.*alloy-dim \([a-z]*\)'.*/\1/p" /etc/skel/.config/sway/config | sort -u); \
842 + [ -n "$verbs" ] \
843 + || { echo "the shipped sway config never calls alloy-dim; the warning stage is not wired up" >&2; exit 1; }; \
844 + for verb in $verbs; do \
845 + grep -q "^$verb)" /usr/bin/alloy-dim \
846 + || { echo "the sway config calls alloy-dim $verb, which the script does not handle" >&2; exit 1; }; \
847 + done; \
848 + getent group video >/dev/null \
849 + || { echo "no video group; alloy-dim would find no writable backlight and dim nothing" >&2; exit 1; }; \
850 + grep -q 'timeout 270' /etc/skel/.config/sway/config \
851 + || { echo "the warning stage is gone from the idle chain; alloy-dim would never be called" >&2; exit 1; }; \
852 + echo "alloy-dim: $(echo "$verbs" | wc -l) verbs wired, video group present"
853 +
815 854 # =====================================================================
816 855 # polkit rules — assert the grant is not inert.
817 856 #
@@ -130,16 +130,51 @@
130 130 # and `alloy sync` carry the same enrollments afterwards.
131 131 exec $term -e alloy setup --if-first-boot
132 132
133 - # Idle: dim to lock at 5 minutes, displays off at 10, and always lock
133 + # Idle: warn at 4.5 minutes, lock at 5, displays off at 10, and always lock
134 134 # before suspend so a closed lid never resumes into a live session.
135 135 # -w makes swayidle wait for the lock to come up before sleeping.
136 136 # Lock styling comes from ~/.config/swaylock/config (it sets daemonize).
137 + #
138 + # The 270-second stage is the warning, and it is why this block has four
139 + # timeouts instead of three. Until it existed the comment here read "dim to
140 + # lock at 5 minutes" and nothing dimmed: full brightness to swaylock with no
141 + # intermediate state, so the lock could only ever arrive as an ambush. Thirty
142 + # seconds of dimmed screen is enough to notice and move the mouse, and the
143 + # `resume` on that stage is what puts the brightness back — including after an
144 + # unlock, since typing a password is input and fires it.
145 + #
146 + # It is also the nearest thing to a grace period on offer: upstream swaylock has
147 + # no --grace, and taking the swaylock-effects fork for one flag is the wrong
148 + # trade for a distro that prefers canonical packages.
137 149 exec swayidle -w \
150 + timeout 270 'alloy-dim down' \
151 + resume 'alloy-dim up' \
138 152 timeout 300 'swaylock -f' \
139 153 timeout 600 'swaymsg "output * power off"' \
140 154 resume 'swaymsg "output * power on"' \
141 155 before-sleep 'swaylock -f'
142 156
157 + # Do not lock over a video.
158 + #
159 + # sway implements the Wayland idle-inhibit protocol and swayidle honours it, so
160 + # an application that registers an inhibitor while it plays already suppresses
161 + # the block above without help. These rules cover the case where that does not
162 + # happen: mpv only inhibits while its stop-screensaver option is on, and a
163 + # browser that loses the inhibitor mid-session gives no sign of it. Fullscreen
164 + # is the condition rather than mere focus, because a windowed video is usually
165 + # something being glanced at rather than watched.
166 + #
167 + # Deliberately three app_ids and not `[app_id=".*"]`. A blanket rule would make
168 + # any fullscreen window hold the lock off, and on a distro whose primary surface
169 + # is a terminal that means walking away from a fullscreen shell leaves the
170 + # machine unlocked indefinitely. The lock is a security control; the exemption
171 + # belongs to the handful of things that are actually playing something. Add an
172 + # app here when a real one is found to need it, and use `class` rather than
173 + # `app_id` if it turns out to be running through XWayland.
174 + for_window [app_id="mpv"] inhibit_idle fullscreen
175 + for_window [app_id="firefox"] inhibit_idle fullscreen
176 + for_window [app_id="imv"] inhibit_idle fullscreen
177 +
143 178 # -------------------------------------------------------------------
144 179 # Binds — session
145 180 # -------------------------------------------------------------------
@@ -1,0 +1,95 @@
1 + #!/bin/sh
2 + # alloy-dim — warn that the lock is coming, by taking the backlight down.
3 + #
4 + # The sway config claimed "dim to lock at 5 minutes" for as long as it has
5 + # existed and nothing dimmed: the screen went from full brightness straight to
6 + # swaylock at 300 seconds. So the lock always arrived as an ambush, and the only
7 + # way to prevent one was to have been touching the keyboard at the moment it
8 + # fired.
9 + #
10 + # This is the thirty seconds of warning. swayidle calls `down` at 270 and `up` on
11 + # the next input, so moving the mouse cancels a lock that has not happened yet.
12 + # It is also the closest thing to a grace period available: upstream swaylock has
13 + # no --grace (only the swaylock-effects fork does), and adopting a fork for one
14 + # flag is a poor trade for a distro that prefers canonical packages.
15 + #
16 + # Nothing here may fail an idle chain. A non-zero exit from a swayidle timeout
17 + # action is not the end of the world, but a machine with no backlight at all — a
18 + # desktop, or the server profile — must not spend every five minutes reporting
19 + # that it cannot dim a screen it does not have. So absence exits 0.
20 + #
21 + # Why sysfs and not brightnessctl: the image ships no brightness CLI. SwayOSD
22 + # raises brightness by writing /sys/class/backlight/<dev>/brightness directly,
23 + # and the Containerfile puts a udev rule on udev's search path that chgrps that
24 + # file to `video` and adds group write. The installer puts the account in `video`
25 + # for exactly this reason. So a session process can write it, and that is the
26 + # whole mechanism the Fn keys already use.
27 +
28 + set -eu
29 +
30 + # The verb is checked before anything else, and before the backlight is looked
31 + # for, so that a typo is an error on every machine rather than only on the ones
32 + # with a panel. Ordered the other way — which is how this was first written — a
33 + # misspelled verb exits 0 on a desktop, because the no-backlight case returns
34 + # before the argument is ever examined. The Containerfile asserts the config and
35 + # this script agree, so a typo should not reach here; making it loud anyway costs
36 + # three lines and removes a way for that assertion to be wrong.
37 + case "${1:-}" in
38 + down | up) ;;
39 + *)
40 + echo "usage: alloy-dim down|up" >&2
41 + exit 2
42 + ;;
43 + esac
44 +
45 + # The first backlight this session can actually write. Iterating rather than
46 + # hardcoding intel_backlight: the name is the driver's, so it differs between
47 + # Intel, AMD and a DDC-driven external panel, and a machine can carry more than
48 + # one. Writability is the test that matters, because an unwritable device means
49 + # the udev rule did not apply and dimming would fail anyway.
50 + device=""
51 + for candidate in /sys/class/backlight/*/; do
52 + [ -w "${candidate}brightness" ] || continue
53 + device="${candidate%/}"
54 + break
55 + done
56 +
57 + # No panel, or no group write on it. Either way there is nothing to dim and
58 + # saying so once per idle period would be noise, not information.
59 + [ -n "$device" ] || exit 0
60 +
61 + # Where the pre-dim level is remembered. In the runtime directory because it is
62 + # tmpfs cleared at logout: a saved level that outlived the session would restore
63 + # a brightness from yesterday, and there is no cleanup path that would catch it.
64 + # Keyed by device so two panels cannot overwrite each other's saved value.
65 + state="${XDG_RUNTIME_DIR:-/tmp}/alloy-dim.$(basename "$device")"
66 +
67 + case "${1:-}" in
68 + down)
69 + # Already dimmed. Without this guard a second `down` would save the dimmed
70 + # value as the level to restore, and the screen would never come back up —
71 + # which is the one failure here that looks like broken hardware.
72 + [ -e "$state" ] && exit 0
73 +
74 + current=$(cat "$device/brightness")
75 + printf '%s\n' "$current" >"$state"
76 +
77 + # A fifth of current, floored at 1. Not zero: zero is a dark panel, which is
78 + # indistinguishable from the display power-off that follows at ten minutes,
79 + # and this has to read as a warning rather than as the screen having gone.
80 + # A fraction rather than a fixed value because the starting point is the
81 + # user's own choice and a fixed target would brighten a dim screen.
82 + dimmed=$((current / 5))
83 + [ "$dimmed" -lt 1 ] && dimmed=1
84 + printf '%s\n' "$dimmed" >"$device/brightness"
85 + ;;
86 + up)
87 + # Nothing saved means this session did not dim, so there is nothing to put
88 + # back. Restoring unconditionally would invent a brightness on any resume
89 + # that followed something other than a dim.
90 + [ -e "$state" ] || exit 0
91 +
92 + printf '%s\n' "$(cat "$state")" >"$device/brightness"
93 + rm -f "$state"
94 + ;;
95 + esac