#!/bin/sh
# alloy-usb-notify — 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, remember is the three keys on
# `alloy usb`, and this is the prompt. Without it a device plugged into an armed
# machine simply does not work, with nothing anywhere a person is looking to
# connect that to a policy decision. "This stick is broken" is the report you get
# instead, and it is not a report anyone can act on.
#
# Run by `usbguard watch --exec`, one process per event. That is 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` and for the console's own action
# half: the log pane can only show a command that exists.
#
# ## The event contract, measured rather than read
#
# `usbguard watch --exec` passes everything in the environment, and ONE plug
# fires this program THREE times. Measured on usbguard-1.1.4 by attaching a
# probe that dumped its own environment:
#
#   USBGUARD_IPC_SIGNAL=Device.PolicyChanged     ... TARGET_OLD + TARGET_NEW
#   USBGUARD_IPC_SIGNAL=Device.PresenceChanged   ... DEVICE_EVENT=Insert, TARGET
#   USBGUARD_IPC_SIGNAL=Device.PolicyApplied     ... TARGET_NEW
#
# So the signal has to be chosen, not merely read: speaking on each of them is
# three notifications for one plug, which is how a person learns to dismiss
# them without looking. `Device.PresenceChanged` with `DEVICE_EVENT=Insert` is
# the one that means "somebody just plugged something in", and it happens once.
#
# `USBGUARD_DEVICE_RULE_ID` is NOT available here, and that is worth writing
# down because it looks like it should be. It reads 4294967294 when a device
# matched no rule and the implicit policy refused it, which would separate "this
# machine has never heard of your device" from "this machine has a rule against
# it", two genuinely different sentences. But it is set only on
# `Device.PolicyChanged` and `Device.PolicyApplied`, never on the
# `PresenceChanged` this program listens to. Measured: the first version of this
# script branched on it and therefore told every person with an unknown device
# that the machine had a rule refusing it, which was false every time.
#
# Switching signals to get the field back is the wrong trade. `PolicyApplied`
# carries no `DEVICE_EVENT`, so it cannot tell a plug from a policy change, and
# it fires when somebody blocks a device deliberately from `alloy usb`, which
# would answer their own decision with a notification telling them how to undo
# it. One true sentence beats two precise ones that fire at the wrong moments.
#
# ## Where the message goes
#
# The ruling scoped this as "`alloy usb` plus a VT1 notice, not a new
# notification daemon or a desktop applet", and the VT1 half of that does not
# survive contact with the case it exists for. A person plugging in a stick is
# in a sway session, and sway owns the display: text written to /dev/tty1 is
# not on their screen. VT1 is right for the keyboard gate, which fires when the
# greeter is the only thing drawing, and wrong here.
#
# So: mako, which is already in the image, through `notify-send`, which
# `alloy-open` and `alloy-shot` already use. That is not a new notification
# daemon; it is the one this system already runs. Reaching it from a system
# unit is the part with no precedent in this tree, and it is two environment
# variables per logged-in user, taken from logind rather than guessed.
#
# The console fallback is kept for the case where there is nobody to notify:
# a machine sitting at the greeter, or a headless one. It is a fallback rather
# than a second channel on purpose, because writing to VT1 draws over whatever
# tuigreet has on it, and doing that on every device plug is worse than the
# silence it replaces.

set -u

# Only the one signal. See the header: the other two describe the same plug.
[ "${USBGUARD_IPC_SIGNAL:-}" = "Device.PresenceChanged" ] || exit 0
[ "${USBGUARD_DEVICE_EVENT:-}" = "Insert" ] || exit 0

# Only a refusal. An allow needs no announcement: the device works, which is
# the whole of what the person wanted to know.
case "${USBGUARD_DEVICE_TARGET:-}" in
    block|reject) ;;
    *) exit 0 ;;
esac

rule="${USBGUARD_DEVICE_RULE:-}"

# The device's own name, out of the rule usbguard just handed us. Falling back
# to the ids, because a device that reports no name still has to be nameable in
# a sentence, and `0781:55a9` is at least something to match against a label.
name=$(printf '%s' "$rule" | sed -n 's/.* name "\([^"]*\)".*/\1/p')
ids=$(printf '%s' "$rule" | sed -n 's/.*\bid \([0-9a-fA-F]\{4\}:[0-9a-fA-F]\{4\}\).*/\1/p')
if [ -z "$name" ]; then
    name="${ids:-an unnamed USB device}"
fi

# One sentence, true whether the device matched no rule or matched one that
# refuses it. See the header for why the field that would separate those is not
# available on this signal.
why="Alloy has not authorized it."

summary="USB device blocked: $name"
body="$why Run 'alloy usb' to allow it."

# The journal always, whatever else happens. It is the one surface that is
# there on every machine and keeps a record after the notification is gone.
printf 'alloy-usb-notify: blocked %s (%s); %s\n' "$name" "${ids:-unknown ids}" "$why" >&2

# Every logged-in PERSON gets told once, because the one who plugged the thing in
# is whoever is sitting there. Read from logind rather than guessed: a hardcoded
# uid 1000 is wrong on a machine with two accounts, and wrong on the first one
# where somebody made a second.
#
# Per user and not per session, which is the whole reason this is two loops.
# Measured: one plug on an ordinary desktop login produced FOUR active sessions
# for the same uid (a wayland one, a couple of tty ones, and the manager), so
# notifying per session delivered four identical popups for one device. The
# notification is per person; the bus is per user; the session is neither.
people=""
for session in $(loginctl list-sessions --no-legend 2>/dev/null | awk '{print $1}'); do
    [ -n "$session" ] || continue
    [ "$(loginctl show-session "$session" -p Active --value 2>/dev/null)" = "yes" ] || continue
    uid=$(loginctl show-session "$session" -p User --value 2>/dev/null)
    case "$uid" in
        ''|*[!0-9]*) continue ;;
    esac
    # A system account with an active session is not a person. greeter runs as
    # uid 971 on this image and would otherwise be told about every device.
    # 1000 is the first human uid here, and `alloy install` creates uid 1000.
    [ "$uid" -ge 1000 ] || continue
    case " $people " in
        *" $uid "*) continue ;;
    esac
    people="$people $uid"
done

told=0
for uid in $people; do
    who=$(getent passwd "$uid" 2>/dev/null | cut -d: -f1)
    [ -n "$who" ] || continue
    runtime="/run/user/$uid"
    [ -d "$runtime" ] || continue
    # The two variables a client needs to find the session bus mako is on.
    # Nothing else from the session is required, and nothing else is taken.
    if runuser -u "$who" -- env \
        XDG_RUNTIME_DIR="$runtime" \
        DBUS_SESSION_BUS_ADDRESS="unix:path=$runtime/bus" \
        notify-send -a Alloy -u critical -i drive-removable-media \
            "$summary" "$body" >/dev/null 2>&1
    then
        told=$((told + 1))
    fi
done

# Nobody to tell, so fall back to the console the way the gate does. This is the
# greeter and the headless case, and it is the only case where drawing on VT1 is
# better than staying quiet.
if [ "$told" -eq 0 ]; then
    {
        printf '\n%s\n' "$summary"
        printf '%s\n\n' "$body"
    } > /dev/tty1 2>/dev/null \
        || {
            printf '\n%s\n' "$summary"
            printf '%s\n\n' "$body"
        } > /dev/console 2>/dev/null \
        || true
fi

exit 0
