Skip to main content

max / alloy

Say so when a USB device is refused The third clause of the ruling (GoingsOn alloy 63de3d4c): "deny unknown, prompt, remember on request". Deny is usbguard and remember is the three keys on alloy usb; without this a refused device is refused in silence, and the report a person can make is "this stick is broken", which nobody can act on. alloy-usb-watch runs `usbguard watch --exec alloy-usb-notify`. A CLI with an argv rather than the D-Bus interface the stock applets use, same correction already made for alloy bluetooth. Three things measured rather than assumed, each of which changed the code. One plug fires the exec THREE times: Device.PolicyChanged, Device.PresenceChanged and Device.PolicyApplied all arrive for one insert, so the signal has to be chosen or one device becomes three popups. PresenceChanged with DEVICE_EVENT=Insert is the one that means somebody just plugged something in. USBGUARD_DEVICE_RULE_ID is NOT set on that signal, though it is on the other two. The first version branched on it to say whether the machine had a rule against the device or had never heard of it, and therefore told everyone with an unknown device that a rule refused it, which was false every time. It says one true thing now. An ordinary desktop login has FOUR active logind sessions for one uid, so notifying per session delivered four identical popups for one device. It is one per person now, and system accounts are skipped so the greeter is not told about hardware nobody is holding. Delivery is mako through notify-send, reached from a system unit with the user's XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS taken from logind rather than guessed. Not a new notification daemon: mako is already in the image and is D-Bus activated on demand, which is why it is absent from a session's process list until the first notification. The VT1 notice the ruling described is the fallback rather than the channel. sway owns the display, so text written to /dev/tty1 is not on a working user's screen, and writing there draws over the greeter. It is used when there is nobody logged in to tell. Verified in a booted graphical session: one plug, one notification reading "USB device blocked: QEMU USB Mouse". At the greeter with nobody logged in, the same event writes to VT1 instead.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
Author: Max Johnson <me@maxj.phd> · 2026-09-08 20:46 UTC
Signed with PGP, not checked
Commit: 1b4a5791149c6473bef7a53ba462f51aa0c85d33
Parent: 0038862
7 files changed, +397 insertions, -6 deletions
M Containerfile +8 -1
@@ -4550,6 +4550,9 @@
4550 4550 # 4. the ConditionFileNotEmpty drop-in so if 3 fails the machine boots
4551 4551 # UNARMED rather than with every
4552 4552 # USB device denied
4553 + # 5. alloy-usb-watch.service so a refusal is said out loud rather
4554 + # than leaving someone holding a stick
4555 + # that does nothing
4553 4556 #
4554 4557 # State 4 is checked by its path and its exact line, and deliberately not by
4555 4558 # asking systemd. `systemctl show usbguard.service -p ConditionFileNotEmpty`
@@ -4582,7 +4585,11 @@
4582 4585 || { echo "$dropin is gone; nothing now stops usbguard arming against an empty policy when seeding fails" >&2; exit 1; }; \
4583 4586 grep -qx "ConditionFileNotEmpty=/etc/usbguard/rules.conf" "$dropin" \
4584 4587 || { echo "$dropin no longer refuses an empty policy, which is the one thing it exists to do" >&2; exit 1; }; \
4585 - echo "usbguard: armed after preset-all, with seeding and the empty-policy refusal both in place"
4588 + systemctl is-enabled alloy-usb-watch.service >/dev/null 2>&1 \
4589 + || { echo "alloy-usb-watch.service is not enabled, so a device this machine refuses is refused in silence and the person holding it has nothing to read" >&2; exit 1; }; \
4590 + test -x /usr/bin/alloy-usb-notify \
4591 + || { echo "/usr/bin/alloy-usb-notify is missing or not executable; the watcher would run nothing on every event" >&2; exit 1; }; \
4592 + echo "usbguard: armed after preset-all, with seeding, the empty-policy refusal and the prompt all in place"
4586 4593
4587 4594 # Template instances have to be enabled by name. `preset-all` iterates over
4588 4595 # the unit *files* that exist, and `alloy-debug-shell@.service` is a template
M docs/CONSOLE.md +6 -4
@@ -317,10 +317,12 @@
317 317 by usbguard`, with the rule for the selected device in the detail pane and three
318 318 keys that act on it.
319 319
320 - A device attached after setup is denied, and today it is denied silently: the
321 - plug-then-prompt hook is `usbguard watch --exec` and nothing runs it yet, so
322 - this screen is where a person finds out. That is the gap worth knowing about
323 - when a stick appears not to work.
320 + A device attached after setup is denied, and the machine says so: a notification
321 + names the device and points here, from `alloy-usb-watch.service` running
322 + `usbguard watch --exec`. So this screen is where a person is sent rather than
323 + somewhere they have to already know about. See docs/STACK.md for what that
324 + measured about usbguard's event stream, including why one plug had to be turned
325 + into one notification rather than three.
324 326
325 327 **Two tabs, because a charger and a keyboard are answered by different
326 328 subsystems.** The bus knows what enumerated and what each interface claims to be;
M docs/STACK.md +7 -1
@@ -487,7 +487,13 @@
487 487
488 488 Verified on a booted machine rather than reasoned about. From an empty policy: seeder starts at +0.779s, writes 3 rules at +0.823s, finishes at +0.839s; usbguard starts at +0.881s and applies the policy at +0.920s with every device `target.new='allow'`. End state is enforcement on, every device authorized, keyboard working. With the seeder prevented from running, usbguard is skipped with `unmet condition check ConditionFileNotEmpty` and the machine boots usable and unprotected, which is the intended failure.
489 489
490 - One thing this does not have and the ruling asked for: the prompt. `usbguard watch --exec` is the hook and nothing runs it yet, so a device plugged in after setup is denied silently and the way to find out is `alloy usb`. That gap is worth closing before this reaches anyone who did not build it.
490 + **The prompt shipped with it**, which is the third clause of the ruling and the thing that keeps the other two honest. `alloy-usb-watch.service` runs `usbguard watch --exec /usr/bin/alloy-usb-notify`, so a refused device produces a notification naming it and saying to run `alloy usb`. A CLI with an argv rather than the D-Bus interface the stock applets use, which is the same correction already made for `alloy bluetooth`.
491 +
492 + Three things about it were measured rather than assumed, and each changed the code. One plug fires the exec **three times** (`Device.PolicyChanged`, `Device.PresenceChanged`, `Device.PolicyApplied`), so the signal has to be chosen or one device becomes three popups; `PresenceChanged` with `DEVICE_EVENT=Insert` is the one that means somebody just plugged something in. `USBGUARD_DEVICE_RULE_ID` is **not** set on that signal, though it is on the other two, and an earlier version branched on it to say whether the machine had a rule against the device or had simply never heard of it; that branch was wrong every time, so the message says one true thing instead. And an ordinary desktop login has **four** active logind sessions for one uid, so notifying per session delivered four identical popups; it is one per person now.
493 +
494 + The delivery is mako, through `notify-send`, reached from a system unit with the user's `XDG_RUNTIME_DIR` and `DBUS_SESSION_BUS_ADDRESS` taken from logind rather than guessed. That is not a new notification daemon: mako is already in the image and is D-Bus activated on demand, which is why it does not appear in a session's process list until the first notification. The VT1 notice the ruling described is kept as the fallback for when there is nobody logged in, because sway owns the display and text written to `/dev/tty1` is not on a working user's screen. It is a fallback rather than a second channel, since writing there draws over the greeter.
495 +
496 + Verified in a booted graphical session: plugging a device usbguard refuses puts one notification on screen reading `USB device blocked: <name>`, and at the greeter with nobody logged in the same event writes to VT1 instead.
491 497
492 498 ## VPN
493 499
@@ -642,3 +642,148 @@
642 642 "close_gate checks whether the daemon is armed before trying to start it",
643 643 );
644 644 }
645 +
646 + // ---- the prompt ----
647 +
648 + fn notify_script() -> String {
649 + repo("usr/bin/alloy-usb-notify")
650 + }
651 +
652 + fn watch_unit() -> String {
653 + repo("etc/systemd/system/alloy-usb-watch.service")
654 + }
655 +
656 + // The third clause of the ruling. Deny is usbguard and remember is the three
657 + // keys on the screen; without this a refused device is refused in silence and
658 + // the report a person can make is "this stick is broken".
659 + #[test]
660 + fn a_refused_device_is_said_out_loud() {
661 + let preset = code(&preset());
662 + assert!(
663 + preset.contains("enable alloy-usb-watch.service"),
664 + "nothing watches usbguard, so enforcement refuses devices with no way \
665 + for the person holding one to find out why",
666 + );
667 + let unit = code(&watch_unit());
668 + assert!(
669 + unit.contains("usbguard watch") && unit.contains("--exec /usr/bin/alloy-usb-notify"),
670 + "the watcher no longer runs the notifier over usbguard's own CLI",
671 + );
672 + }
673 +
674 + // One plug fires the exec three times. Measured on usbguard-1.1.4:
675 + // Device.PolicyChanged, Device.PresenceChanged and Device.PolicyApplied all
676 + // arrive for a single insert, so speaking on each is three popups for one
677 + // device, which is how someone learns to dismiss them without reading.
678 + #[test]
679 + fn the_notifier_speaks_once_per_plug_and_only_for_a_refusal() {
680 + let code = code(&notify_script());
681 + assert!(
682 + code.contains(r#"= "Device.PresenceChanged""#),
683 + "the notifier no longer pins itself to one signal, so one plug is \
684 + several notifications",
685 + );
686 + assert!(
687 + code.contains(r#"= "Insert""#),
688 + "the notifier no longer checks that the device was inserted, so it \
689 + also speaks when one is unplugged",
690 + );
691 + assert!(
692 + code.contains("block|reject"),
693 + "the notifier no longer restricts itself to refusals; an allowed \
694 + device needs no announcement because it works",
695 + );
696 + }
697 +
698 + // `USBGUARD_DEVICE_RULE_ID` is not set on the signal this listens to, and an
699 + // earlier version branched on it and therefore told every person with an
700 + // unknown device that the machine had a rule refusing it. It was false every
701 + // time. See the script's header.
702 + #[test]
703 + fn the_notifier_does_not_claim_to_know_which_rule_refused_the_device() {
704 + let code = code(&notify_script());
705 + assert!(
706 + !code.contains("USBGUARD_DEVICE_RULE_ID"),
707 + "the notifier reads USBGUARD_DEVICE_RULE_ID, which is absent on \
708 + Device.PresenceChanged, so whichever branch it takes is a guess",
709 + );
710 + }
711 +
712 + // Per person, not per session. Measured: an ordinary desktop login had FOUR
713 + // active sessions for one uid, and notifying per session delivered four
714 + // identical popups for one device.
715 + #[test]
716 + fn the_notifier_tells_each_person_once_rather_than_each_session() {
717 + let code = code(&notify_script());
718 + let collect = code
719 + .find("people=\"\"")
720 + .expect("the notifier no longer collects the set of people to tell");
721 + let send = code
722 + .find("notify-send")
723 + .expect("the notifier no longer sends a notification");
724 + assert!(
725 + collect < send,
726 + "the notifier sends inside the session loop, so one device produces \
727 + one notification per session rather than one per person",
728 + );
729 + assert!(
730 + code.contains("*\" $uid \"*"),
731 + "the notifier no longer skips a uid it has already collected",
732 + );
733 + // The greeter runs as its own uid and would otherwise be told about every
734 + // device plugged in at the login screen.
735 + assert!(
736 + code.contains("-ge 1000"),
737 + "the notifier no longer excludes system accounts, so the greeter gets \
738 + notified about hardware nobody is holding",
739 + );
740 + }
741 +
742 + // The console is the fallback and not a second channel. Writing to VT1 draws
743 + // over whatever tuigreet has on it, which is right when there is nobody logged
744 + // in to notify and wrong on every device plug.
745 + #[test]
746 + fn the_console_notice_is_only_for_when_there_is_nobody_to_tell() {
747 + let code = code(&notify_script());
748 + let guard = code
749 + .find(r#"if [ "$told" -eq 0 ]"#)
750 + .expect("the notifier no longer gates its console write on having told nobody");
751 + let tty = code
752 + .find("/dev/tty1")
753 + .expect("the notifier no longer has a console fallback at all");
754 + assert!(
755 + guard < tty,
756 + "the notifier writes to the console unconditionally, so every plug \
757 + scribbles over the greeter",
758 + );
759 + }
760 +
761 + // The watcher must not be a reason for enforcement to start. Same rule as the
762 + // seeder and the gate: only the preset line and a person may arm this machine.
763 + #[test]
764 + fn the_watcher_never_pulls_the_daemon_in() {
765 + let unit = code(&watch_unit());
766 + for forbidden in [
767 + "Wants=usbguard.service",
768 + "Requires=usbguard.service",
769 + "BindsTo=usbguard.service",
770 + ] {
771 + assert!(
772 + !unit.contains(forbidden),
773 + "the watcher pulls usbguard in with {forbidden}, which makes \
774 + watching for refusals a way to start refusing",
775 + );
776 + }
777 + assert!(
778 + unit.contains("ConditionFileNotEmpty=/etc/usbguard/rules.conf"),
779 + "the watcher no longer shares usbguard's own condition, so on a machine \
780 + with no policy it runs against a daemon that was skipped",
781 + );
782 + // The gate stops the daemon whenever the machine loses its keyboard. A
783 + // watcher that exited on disconnect would be gone for good after the first
784 + // such episode, on exactly the machine that has already had one problem.
785 + assert!(
786 + unit.contains("--wait") && !unit.contains("--once"),
787 + "the watcher no longer survives the daemon going away and coming back",
788 + );
789 + }
@@ -252,3 +252,18 @@
252 252 # so, from `alloy usb`. That is the ruling, and it is the one default in this
253 253 # file that makes the machine do less than it did yesterday.
254 254 enable usbguard.service
255 +
256 + # And the half of the ruling that tells a person what just happened. usbguard
257 + # denies a device it has not been told about; without this the denial is silent,
258 + # and the report you get is "this stick is broken", which nobody can act on.
259 + #
260 + # `usbguard watch --exec` is the hook, so this fronts a CLI with an argv rather
261 + # than the D-Bus interface the stock applets use, which is the same correction
262 + # already made for `alloy bluetooth`. The message goes to whoever is logged in,
263 + # through the notification daemon this image already runs, and falls back to the
264 + # console when there is nobody to tell.
265 + #
266 + # Both profiles. A server has nobody sitting at it, and the journal line this
267 + # writes is the only record that a device was refused, which is exactly what
268 + # someone reads when a machine they cannot see does not see its own hardware.
269 + enable alloy-usb-watch.service
@@ -1,0 +1,51 @@
1 + # Watch usbguard for refused devices, so a person is told rather than left
2 + # holding something that does not work.
3 + #
4 + # The reasoning is in usr/bin/alloy-usb-notify; this file is only what keeps it
5 + # listening. `usbguard watch` is a long-lived IPC client that runs the program
6 + # once per event, which is why this is a service rather than something the
7 + # console starts.
8 + #
9 + # ## Why it is not tied to usbguard.service the obvious ways
10 + #
11 + # BindsTo= and Requires= both PULL THE DAEMON IN, and this must never be a
12 + # reason for enforcement to start. Same rule as the seeder and the gate: the
13 + # only thing allowed to arm this machine is the preset line and a person.
14 + #
15 + # Requisite= would avoid that but fails the unit when the daemon is not running,
16 + # which on a machine with no policy is the normal state and would leave a failed
17 + # unit sitting in `systemctl --failed` saying nothing useful. So the condition is
18 + # the same one usbguard itself carries: no policy, no enforcement, nothing to
19 + # watch, and both units are skipped quietly for the same reason on the same
20 + # machines.
21 + #
22 + # ## Why it survives the daemon going away
23 + #
24 + # The keyboard gate stops usbguard whenever the machine loses its keyboard, and
25 + # starts it again when one comes back. A watcher that exited on disconnect would
26 + # be gone for good after the first such episode, on exactly the machine that has
27 + # already had one problem. `usbguard watch --wait` reconnects rather than
28 + # exiting. `--once` is the flag that would make it exit, and it is deliberately
29 + # not passed. Restart=always covers the rest.
30 + #
31 + # Measured: with the daemon stopped and started underneath it, the watcher stays
32 + # up and keeps delivering.
33 +
34 + [Unit]
35 + Description=Tell someone when a USB device is refused
36 + Documentation=https://makenot.work/git/max/alloy
37 + After=usbguard.service
38 + ConditionPathExists=/usr/bin/usbguard
39 + # The same condition usbguard.service carries. A machine with no policy is not
40 + # enforcing, so there is nothing to report and this is skipped rather than
41 + # failed. See etc/systemd/system/usbguard.service.d/.
42 + ConditionFileNotEmpty=/etc/usbguard/rules.conf
43 +
44 + [Service]
45 + Type=simple
46 + ExecStart=/usr/bin/usbguard watch --wait --exec /usr/bin/alloy-usb-notify
47 + Restart=always
48 + RestartSec=2
49 +
50 + [Install]
51 + WantedBy=multi-user.target
@@ -1,0 +1,165 @@
1 + #!/bin/sh
2 + # alloy-usb-notify — say so when a USB device is refused.
3 + #
4 + # The third clause of the ruling (GoingsOn alloy 63de3d4c): "deny unknown,
5 + # PROMPT, remember on request". Deny is usbguard, remember is the three keys on
6 + # `alloy usb`, and this is the prompt. Without it a device plugged into an armed
7 + # machine simply does not work, with nothing anywhere a person is looking to
8 + # connect that to a policy decision. "This stick is broken" is the report you get
9 + # instead, and it is not a report anyone can act on.
10 + #
11 + # Run by `usbguard watch --exec`, one process per event. That is a CLI with an
12 + # argv rather than the D-Bus interface the stock applets use, which is the same
13 + # correction already made for `alloy bluetooth` and for the console's own action
14 + # half: the log pane can only show a command that exists.
15 + #
16 + # ## The event contract, measured rather than read
17 + #
18 + # `usbguard watch --exec` passes everything in the environment, and ONE plug
19 + # fires this program THREE times. Measured on usbguard-1.1.4 by attaching a
20 + # probe that dumped its own environment:
21 + #
22 + # USBGUARD_IPC_SIGNAL=Device.PolicyChanged ... TARGET_OLD + TARGET_NEW
23 + # USBGUARD_IPC_SIGNAL=Device.PresenceChanged ... DEVICE_EVENT=Insert, TARGET
24 + # USBGUARD_IPC_SIGNAL=Device.PolicyApplied ... TARGET_NEW
25 + #
26 + # So the signal has to be chosen, not merely read: speaking on each of them is
27 + # three notifications for one plug, which is how a person learns to dismiss
28 + # them without looking. `Device.PresenceChanged` with `DEVICE_EVENT=Insert` is
29 + # the one that means "somebody just plugged something in", and it happens once.
30 + #
31 + # `USBGUARD_DEVICE_RULE_ID` is NOT available here, and that is worth writing
32 + # down because it looks like it should be. It reads 4294967294 when a device
33 + # matched no rule and the implicit policy refused it, which would separate "this
34 + # machine has never heard of your device" from "this machine has a rule against
35 + # it", two genuinely different sentences. But it is set only on
36 + # `Device.PolicyChanged` and `Device.PolicyApplied`, never on the
37 + # `PresenceChanged` this program listens to. Measured: the first version of this
38 + # script branched on it and therefore told every person with an unknown device
39 + # that the machine had a rule refusing it, which was false every time.
40 + #
41 + # Switching signals to get the field back is the wrong trade. `PolicyApplied`
42 + # carries no `DEVICE_EVENT`, so it cannot tell a plug from a policy change, and
43 + # it fires when somebody blocks a device deliberately from `alloy usb`, which
44 + # would answer their own decision with a notification telling them how to undo
45 + # it. One true sentence beats two precise ones that fire at the wrong moments.
46 + #
47 + # ## Where the message goes
48 + #
49 + # The ruling scoped this as "`alloy usb` plus a VT1 notice, not a new
50 + # notification daemon or a desktop applet", and the VT1 half of that does not
51 + # survive contact with the case it exists for. A person plugging in a stick is
52 + # in a sway session, and sway owns the display: text written to /dev/tty1 is
53 + # not on their screen. VT1 is right for the keyboard gate, which fires when the
54 + # greeter is the only thing drawing, and wrong here.
55 + #
56 + # So: mako, which is already in the image, through `notify-send`, which
57 + # `alloy-open` and `alloy-shot` already use. That is not a new notification
58 + # daemon; it is the one this system already runs. Reaching it from a system
59 + # unit is the part with no precedent in this tree, and it is two environment
60 + # variables per logged-in user, taken from logind rather than guessed.
61 + #
62 + # The console fallback is kept for the case where there is nobody to notify:
63 + # a machine sitting at the greeter, or a headless one. It is a fallback rather
64 + # than a second channel on purpose, because writing to VT1 draws over whatever
65 + # tuigreet has on it, and doing that on every device plug is worse than the
66 + # silence it replaces.
67 +
68 + set -u
69 +
70 + # Only the one signal. See the header: the other two describe the same plug.
71 + [ "${USBGUARD_IPC_SIGNAL:-}" = "Device.PresenceChanged" ] || exit 0
72 + [ "${USBGUARD_DEVICE_EVENT:-}" = "Insert" ] || exit 0
73 +
74 + # Only a refusal. An allow needs no announcement: the device works, which is
75 + # the whole of what the person wanted to know.
76 + case "${USBGUARD_DEVICE_TARGET:-}" in
77 + block|reject) ;;
78 + *) exit 0 ;;
79 + esac
80 +
81 + rule="${USBGUARD_DEVICE_RULE:-}"
82 +
83 + # The device's own name, out of the rule usbguard just handed us. Falling back
84 + # to the ids, because a device that reports no name still has to be nameable in
85 + # a sentence, and `0781:55a9` is at least something to match against a label.
86 + name=$(printf '%s' "$rule" | sed -n 's/.* name "\([^"]*\)".*/\1/p')
87 + ids=$(printf '%s' "$rule" | sed -n 's/.*\bid \([0-9a-fA-F]\{4\}:[0-9a-fA-F]\{4\}\).*/\1/p')
88 + if [ -z "$name" ]; then
89 + name="${ids:-an unnamed USB device}"
90 + fi
91 +
92 + # One sentence, true whether the device matched no rule or matched one that
93 + # refuses it. See the header for why the field that would separate those is not
94 + # available on this signal.
95 + why="Alloy has not authorized it."
96 +
97 + summary="USB device blocked: $name"
98 + body="$why Run 'alloy usb' to allow it."
99 +
100 + # The journal always, whatever else happens. It is the one surface that is
101 + # there on every machine and keeps a record after the notification is gone.
102 + printf 'alloy-usb-notify: blocked %s (%s); %s\n' "$name" "${ids:-unknown ids}" "$why" >&2
103 +
104 + # Every logged-in PERSON gets told once, because the one who plugged the thing in
105 + # is whoever is sitting there. Read from logind rather than guessed: a hardcoded
106 + # uid 1000 is wrong on a machine with two accounts, and wrong on the first one
107 + # where somebody made a second.
108 + #
109 + # Per user and not per session, which is the whole reason this is two loops.
110 + # Measured: one plug on an ordinary desktop login produced FOUR active sessions
111 + # for the same uid (a wayland one, a couple of tty ones, and the manager), so
112 + # notifying per session delivered four identical popups for one device. The
113 + # notification is per person; the bus is per user; the session is neither.
114 + people=""
115 + for session in $(loginctl list-sessions --no-legend 2>/dev/null | awk '{print $1}'); do
116 + [ -n "$session" ] || continue
117 + [ "$(loginctl show-session "$session" -p Active --value 2>/dev/null)" = "yes" ] || continue
118 + uid=$(loginctl show-session "$session" -p User --value 2>/dev/null)
119 + case "$uid" in
120 + ''|*[!0-9]*) continue ;;
121 + esac
122 + # A system account with an active session is not a person. greeter runs as
123 + # uid 971 on this image and would otherwise be told about every device.
124 + # 1000 is the first human uid here, and `alloy install` creates uid 1000.
125 + [ "$uid" -ge 1000 ] || continue
126 + case " $people " in
127 + *" $uid "*) continue ;;
128 + esac
129 + people="$people $uid"
130 + done
131 +
132 + told=0
133 + for uid in $people; do
134 + who=$(getent passwd "$uid" 2>/dev/null | cut -d: -f1)
135 + [ -n "$who" ] || continue
136 + runtime="/run/user/$uid"
137 + [ -d "$runtime" ] || continue
138 + # The two variables a client needs to find the session bus mako is on.
139 + # Nothing else from the session is required, and nothing else is taken.
140 + if runuser -u "$who" -- env \
141 + XDG_RUNTIME_DIR="$runtime" \
142 + DBUS_SESSION_BUS_ADDRESS="unix:path=$runtime/bus" \
143 + notify-send -a Alloy -u critical -i drive-removable-media \
144 + "$summary" "$body" >/dev/null 2>&1
145 + then
146 + told=$((told + 1))
147 + fi
148 + done
149 +
150 + # Nobody to tell, so fall back to the console the way the gate does. This is the
151 + # greeter and the headless case, and it is the only case where drawing on VT1 is
152 + # better than staying quiet.
153 + if [ "$told" -eq 0 ]; then
154 + {
155 + printf '\n%s\n' "$summary"
156 + printf '%s\n\n' "$body"
157 + } > /dev/tty1 2>/dev/null \
158 + || {
159 + printf '\n%s\n' "$summary"
160 + printf '%s\n\n' "$body"
161 + } > /dev/console 2>/dev/null \
162 + || true
163 + fi
164 +
165 + exit 0