fix: deliver test notifications into the classroom session

The classroom desktop belongs to the classroom user and mako sits on
that user's D-Bus session. An administrator over SSH is in a different
session where nothing owns org.freedesktop.Notifications, so notify-send
asked D-Bus to start a second mako, which had no display to open and
timed out with NoReply.

Find the running sway process for the classroom user and deliver into
its session instead. XDG_RUNTIME_DIR and the bus address are read from
that process, falling back to /run/user/<uid> and its bus socket when
the environment cannot be read — root has ptrace access on the endpoint,
but that is not somewhere to be brittle. The Wayland display is read the
same way, and failing that from the wayland-N.lock sway holds open, so
no socket name is ever assumed. notify-send only needs the bus, so a
display that cannot be resolved is not worth refusing to send over.

Refuses to send when mako is not already running, rather than letting
D-Bus start one, and says plainly what is wrong when there is no
session, no privileges, or no bus socket. --dry-run reports what it
resolved without sending.

Behaviour inside the session is unchanged, and the sample notifications
and styling are untouched.

The tests stand a real background process in for sway so /proc/<pid>/environ
is genuine rather than mocked, and cover both invocation paths plus the
fallbacks and every refusal. The build runs them.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-04 17:38:44 -05:00
co-authored by Claude Opus 5
parent 59fbc54d65
commit ffd18edf7e
4 changed files with 509 additions and 10 deletions
+22
View File
@@ -56,6 +56,28 @@ hook. Minimising is the dock's Browser button or `Super+M`.
renders in the critical style and does not time out
- [ ] Notifications appear over a fullscreen video
Over SSH from an administrator account, run it with sudo:
```
sudo classroom-notify-test --dry-run # show the session it resolved
sudo classroom-notify-test # send into the classroom desktop
```
The classroom desktop belongs to the classroom user, and mako sits on
that user's D-Bus session. A plain notify-send from an SSH session lands
in the administrator's own session, where nothing owns
org.freedesktop.Notifications, so D-Bus tries to start a second mako with
no display and the call times out with NoReply. The script finds the
running sway process for the classroom user and delivers into its
session instead. It never starts mako.
- [ ] `sudo classroom-notify-test` from an SSH session displays on the
classroom screen
- [ ] `sudo classroom-notify-test --dry-run` reports the resolved
XDG_RUNTIME_DIR, bus address and Wayland display
- [ ] Running it as an unprivileged admin account explains it needs sudo
- [ ] `tests/test-classroom-notify-test.sh` passes (also run by the build)
## DVD
With `DVD_ENABLED=false`:
+183 -9
View File
@@ -7,24 +7,198 @@ set -euo pipefail
# classroom-notify-test
# classroom-notify-test "Title" "Body text"
# classroom-notify-test --critical "Parent Call" "Room 3 needs a parent."
# sudo classroom-notify-test --dry-run
#
# The classroom desktop runs as the classroom user, and mako is attached
# to that user's D-Bus session. An administrator over SSH sits in a
# different session where nothing owns org.freedesktop.Notifications, so
# a plain notify-send there asks D-Bus to start a second mako, which has
# no display to open and times out with NoReply. Find the running desktop
# session and deliver into it instead.
CLASSROOM_USER=classroom
urgency=normal
dry_run=false
if [[ "${1:-}" == "--critical" ]]; then
urgency=critical
shift
elif [[ "${1:-}" == "--low" ]]; then
urgency=low
shift
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--critical)
urgency=critical
shift
;;
--low)
urgency=low
shift
;;
--dry-run)
dry_run=true
shift
;;
--)
shift
break
;;
-*)
echo "classroom-notify-test: unknown option $1" >&2
echo "usage: classroom-notify-test [--low|--critical] [--dry-run] [title] [body]" >&2
exit 2
;;
*)
break
;;
esac
done
title="${1:-DVD Ready}"
body="${2:-A disc is ready to play.}"
notify-send \
fail() {
echo "classroom-notify-test: $1" >&2
exit 1
}
# Another process's environment, or nothing. Reading this needs ptrace
# access, which root has on the endpoint but not, for instance, inside a
# container with CAP_SYS_PTRACE dropped — so every caller has to cope
# with getting nothing back.
read_environ() {
{ tr '\0' '\n' < "/proc/$1/environ"; } 2>/dev/null || true
}
environ_value() {
printf '%s\n' "$1" | sed -n "s/^$2=//p" | head -n 1
}
# sway holds its own wayland-N.lock open, so the display name can be read
# back off the process instead of being guessed at.
display_from_lock() {
local link target
for link in "/proc/$1/fd"/*; do
target="$(readlink "$link" 2>/dev/null || true)"
case "$target" in
*/wayland-*.lock)
target="${target%.lock}"
echo "${target##*/}"
return 0
;;
esac
done
return 0
}
classroom_uid="$(id -u "$CLASSROOM_USER" 2>/dev/null || true)"
[[ -n "$classroom_uid" ]] ||
fail "there is no $CLASSROOM_USER user on this machine."
current_uid="$(id -u)"
runner=()
session="this session"
source_note="inherited from this session"
if [[ "$current_uid" == "$classroom_uid" &&
-n "${WAYLAND_DISPLAY:-}" &&
-n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
# Already inside the classroom desktop session.
sway_pid="n/a"
runtime_dir="${XDG_RUNTIME_DIR:-}"
bus="$DBUS_SESSION_BUS_ADDRESS"
wayland_display="$WAYLAND_DISPLAY"
else
session="the $CLASSROOM_USER desktop session"
sway_pid="$(pgrep -u "$CLASSROOM_USER" -x sway | head -n 1 || true)"
[[ -n "$sway_pid" ]] ||
fail "no classroom desktop session is running: there is no sway process for user $CLASSROOM_USER."
# Checked before anything that needs to look inside the session, so
# an administrator gets told what to do rather than a permission
# error from somewhere further down.
if [[ "$current_uid" != "$classroom_uid" && "$current_uid" != "0" ]]; then
fail "run this as root (sudo classroom-notify-test) or as the $CLASSROOM_USER user."
fi
environment_block="$(read_environ "$sway_pid")"
if [[ -n "$environment_block" ]]; then
source_note="read from sway (pid $sway_pid)"
else
source_note="sway's environment was unreadable; using session defaults"
fi
runtime_dir="$(environ_value "$environment_block" XDG_RUNTIME_DIR)"
runtime_dir="${runtime_dir:-/run/user/$classroom_uid}"
[[ -d "$runtime_dir" ]] ||
fail "the classroom session runtime directory $runtime_dir does not exist."
bus="$(environ_value "$environment_block" DBUS_SESSION_BUS_ADDRESS)"
bus="${bus:-unix:path=$runtime_dir/bus}"
case "$bus" in
unix:path=*)
bus_socket="${bus#unix:path=}"
bus_socket="${bus_socket%%,*}"
[[ -S "$bus_socket" ]] ||
fail "the classroom session bus socket $bus_socket does not exist. Is the desktop session fully up?"
;;
esac
wayland_display="$(environ_value "$environment_block" WAYLAND_DISPLAY)"
if [[ -z "$wayland_display" ]]; then
wayland_display="$(display_from_lock "$sway_pid")"
fi
environment=(
"XDG_RUNTIME_DIR=$runtime_dir"
"DBUS_SESSION_BUS_ADDRESS=$bus"
)
# notify-send only needs the bus. The display is passed through for
# the sake of anything else in the session, and its absence is not
# worth refusing to send over.
if [[ -n "$wayland_display" ]]; then
environment+=("WAYLAND_DISPLAY=$wayland_display")
fi
if [[ "$current_uid" == "$classroom_uid" ]]; then
runner=(env "${environment[@]}")
else
runner=(runuser -u "$CLASSROOM_USER" -- env "${environment[@]}")
fi
fi
# Deliver to the mako that is already running. Sending into a session
# without one would have D-Bus start a second mako, which is exactly the
# failure this script exists to avoid.
pgrep -u "$CLASSROOM_USER" -x mako >/dev/null 2>&1 ||
fail "mako is not running in the classroom session, and this will not start it. Check with: pgrep -u $CLASSROOM_USER -a mako"
if [[ "$dry_run" == true ]]; then
echo "Would notify $session:"
echo " sway pid: $sway_pid"
echo " session environment: $source_note"
echo " XDG_RUNTIME_DIR: $runtime_dir"
echo " DBUS_SESSION_BUS_ADDRESS: $bus"
echo " WAYLAND_DISPLAY: ${wayland_display:-(not resolved)}"
echo " urgency: $urgency"
echo " title: $title"
echo " body: $body"
exit 0
fi
"${runner[@]}" notify-send \
--app-name="Ekklesia Classroom" \
--urgency="$urgency" \
-- \
"$title" "$body"
echo "Sent $urgency notification: $title"
echo "Sent $urgency notification to $session: $title"
+292
View File
@@ -0,0 +1,292 @@
#!/bin/bash
set -uo pipefail
# Tests the session resolution in classroom-notify-test.
#
# sway is stood in for by a real background process started with a known
# environment, so /proc/<pid>/environ is genuine rather than mocked. id,
# pgrep, runuser and notify-send are shimmed onto PATH so every branch
# can be reached from an ordinary account.
#
# tests/test-classroom-notify-test.sh
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$ROOT/scripts/classroom-notify-test"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"; kill "${FAKE_SWAY_PID:-}" 2>/dev/null' EXIT
passed=0
failed=0
check() {
local label="$1" expected="$2" actual="$3"
if [[ "$actual" == *"$expected"* ]]; then
echo " ok $label"
passed=$((passed + 1))
else
echo " FAIL $label"
echo " expected to contain: $expected"
echo " got: $actual"
failed=$((failed + 1))
fi
}
check_absent() {
local label="$1" unexpected="$2" actual="$3"
if [[ "$actual" != *"$unexpected"* ]]; then
echo " ok $label"
passed=$((passed + 1))
else
echo " FAIL $label"
echo " expected NOT to contain: $unexpected"
echo " got: $actual"
failed=$((failed + 1))
fi
}
# ---------------------------------------------------------------- shims
mkdir -p "$WORK/bin"
cat > "$WORK/bin/id" <<'SHIM'
#!/bin/bash
if [[ "${1:-}" == "-u" && -n "${2:-}" ]]; then
echo "$FAKE_CLASSROOM_UID"
exit 0
fi
if [[ "${1:-}" == "-u" ]]; then
echo "$FAKE_CURRENT_UID"
exit 0
fi
exec /usr/bin/id "$@"
SHIM
cat > "$WORK/bin/pgrep" <<'SHIM'
#!/bin/bash
case "${!#}" in
sway)
[[ -n "${FAKE_SWAY_PID:-}" ]] || exit 1
echo "$FAKE_SWAY_PID"
;;
mako)
[[ "${FAKE_MAKO:-1}" == "1" ]] || exit 1
echo 4242
;;
*)
exit 1
;;
esac
SHIM
cat > "$WORK/bin/runuser" <<'SHIM'
#!/bin/bash
echo "runuser $*" >> "$RUNUSER_LOG"
shift 2
[[ "${1:-}" == "--" ]] && shift
exec "$@"
SHIM
cat > "$WORK/bin/notify-send" <<'SHIM'
#!/bin/bash
{
echo "args: $*"
echo "XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-}"
echo "DBUS_SESSION_BUS_ADDRESS=${DBUS_SESSION_BUS_ADDRESS:-}"
echo "WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-}"
} >> "$NOTIFY_LOG"
SHIM
chmod +x "$WORK/bin"/*
export PATH="$WORK/bin:$PATH"
export NOTIFY_LOG="$WORK/notify.log"
export RUNUSER_LOG="$WORK/runuser.log"
export FAKE_CLASSROOM_UID=4000
export FAKE_CURRENT_UID=4000
RUNTIME="$WORK/run"
mkdir -p "$RUNTIME"
# A real unix socket, because the script checks the bus is actually there.
python3 -c 'import socket, sys; socket.socket(socket.AF_UNIX).bind(sys.argv[1])' \
"$RUNTIME/bus"
run() {
: > "$NOTIFY_LOG"
: > "$RUNUSER_LOG"
"$SCRIPT" "$@" 2>&1
}
start_fake_sway() {
env "$@" sleep 300 &
FAKE_SWAY_PID=$!
export FAKE_SWAY_PID
# Give the exec time to land so /proc/<pid>/environ is populated.
while [[ ! -r "/proc/$FAKE_SWAY_PID/environ" ]]; do sleep 0.05; done
}
stop_fake_sway() {
[[ -n "${FAKE_SWAY_PID:-}" ]] && kill "$FAKE_SWAY_PID" 2>/dev/null
wait "${FAKE_SWAY_PID:-}" 2>/dev/null
unset FAKE_SWAY_PID
}
unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS
# --------------------------------------------- 1. resolve from environ
echo "1. resolves the session from sway's environment"
start_fake_sway \
XDG_RUNTIME_DIR="$RUNTIME" \
DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" \
WAYLAND_DISPLAY=wayland-7
out="$(run)"
check "reports delivery to the classroom session" "Sent normal notification to the classroom desktop session" "$out"
log="$(cat "$NOTIFY_LOG")"
check "runtime dir taken from sway" "XDG_RUNTIME_DIR=$RUNTIME" "$log"
check "bus address taken from sway" "DBUS_SESSION_BUS_ADDRESS=unix:path=$RUNTIME/bus" "$log"
check "wayland display from sway" "WAYLAND_DISPLAY=wayland-7" "$log"
check "styling preserved" "--app-name=Ekklesia Classroom" "$log"
check "default sample title" "DVD Ready" "$log"
check "default sample body" "A disc is ready to play." "$log"
# ------------------------------------------------- 2. dry run reporting
echo "2. --dry-run reports the resolved session and sends nothing"
out="$(run --dry-run)"
check "shows the sway pid" "sway pid: $FAKE_SWAY_PID" "$out"
check "shows the runtime dir" "XDG_RUNTIME_DIR: $RUNTIME" "$out"
check "shows the wayland display" "WAYLAND_DISPLAY: wayland-7" "$out"
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
# ------------------------------------------------------ 3. urgency/args
echo "3. urgency and custom text still work"
out="$(run --critical "Parent Call" "Room 3 needs a parent.")"
log="$(cat "$NOTIFY_LOG")"
check "critical urgency passed" "--urgency=critical" "$log"
check "custom title passed" "Parent Call" "$log"
check "custom body passed" "Room 3 needs a parent." "$log"
# ------------------------------------------------ 4. root uses runuser
echo "4. root delivers as the classroom user"
FAKE_CURRENT_UID=0
out="$(run)"
check "runuser targets classroom" "runuser -u classroom --" "$(cat "$RUNUSER_LOG")"
check "notification still sent" "--app-name=Ekklesia Classroom" "$(cat "$NOTIFY_LOG")"
FAKE_CURRENT_UID=4000
# ------------------------------------ 5. unprivileged admin is told how
echo "5. an unprivileged administrator gets told what to do"
FAKE_CURRENT_UID=9999
out="$(run)"
check "explains it needs root" "run this as root (sudo classroom-notify-test)" "$out"
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
FAKE_CURRENT_UID=4000
# ----------------------------------------- 6. mako must already be up
echo "6. refuses to let D-Bus start a second mako"
export FAKE_MAKO=0
out="$(run)"
check "explains mako is not running" "mako is not running in the classroom session" "$out"
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
unset FAKE_MAKO
stop_fake_sway
# --------------------------------- 7. display derived from the lock fd
echo "7. derives the display from sway's wayland lock when environ has none"
touch "$RUNTIME/wayland-9.lock"
env -u WAYLAND_DISPLAY \
XDG_RUNTIME_DIR="$RUNTIME" \
DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus" \
bash -c "exec 9< '$RUNTIME/wayland-9.lock'; sleep 300" &
FAKE_SWAY_PID=$!
export FAKE_SWAY_PID
while [[ ! -r "/proc/$FAKE_SWAY_PID/fd/9" ]]; do sleep 0.05; done
out="$(run --dry-run)"
check "display derived from the lock" "WAYLAND_DISPLAY: wayland-9" "$out"
stop_fake_sway
# ------------------------------------------- 8. no session at all
echo "8. says so clearly when no classroom session is running"
out="$(run)"
check "explains there is no session" "no classroom desktop session is running" "$out"
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
# ------------------------------------- 9. falls back when environ is thin
echo "9. falls back to session defaults when sway's environment is unreadable"
start_fake_sway XDG_RUNTIME_DIR="$RUNTIME"
out="$(run --dry-run)"
check "derives the bus from the runtime dir" \
"DBUS_SESSION_BUS_ADDRESS: unix:path=$RUNTIME/bus" "$out"
out="$(run)"
check "still delivers" "Sent normal notification to the classroom desktop session" "$out"
check "bus reached notify-send" "DBUS_SESSION_BUS_ADDRESS=unix:path=$RUNTIME/bus" \
"$(cat "$NOTIFY_LOG")"
stop_fake_sway
# ------------------------------------------ 10. missing bus is explained
echo "10. explains a missing session bus instead of hanging"
MISSING="$WORK/empty"
mkdir -p "$MISSING"
start_fake_sway XDG_RUNTIME_DIR="$MISSING"
out="$(run)"
check "names the missing socket" "bus socket $MISSING/bus does not exist" "$out"
check_absent "nothing was sent" "args:" "$(cat "$NOTIFY_LOG")"
stop_fake_sway
# ----------------------------- 11. already inside the classroom session
echo "11. sends directly when already inside the classroom session"
# No sway process to find: an in-session caller must not need one.
export WAYLAND_DISPLAY=wayland-4
export DBUS_SESSION_BUS_ADDRESS="unix:path=$RUNTIME/bus"
export XDG_RUNTIME_DIR="$RUNTIME"
out="$(run)"
check "reports delivery to this session" "Sent normal notification to this session" "$out"
check "notification was sent" "--app-name=Ekklesia Classroom" "$(cat "$NOTIFY_LOG")"
check "inherits the session display" "WAYLAND_DISPLAY=wayland-4" "$(cat "$NOTIFY_LOG")"
check_absent "did not go through runuser" "runuser" "$(cat "$RUNUSER_LOG")"
out="$(run --dry-run)"
check "dry run names this session" "Would notify this session" "$out"
unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR
# ----------------------------------------------------------- summary
echo
echo "passed: $passed failed: $failed"
[[ "$failed" -eq 0 ]]
+12 -1
View File
@@ -24,7 +24,7 @@ OUTPUT="$ROOT/dist/ekklesia-classroom_${VERSION}_amd64.deb"
# drift out of date as classroom commands are added.
echo "Validating sources"
for script in "$ROOT/packaging/debian/postinst" "$ROOT"/scripts/*; do
for script in "$ROOT/packaging/debian/postinst" "$ROOT"/scripts/* "$ROOT"/tests/*; do
[[ -f "$script" ]] || continue
case "$(head -n 1 "$script")" in
@@ -52,6 +52,17 @@ done
find "$ROOT/scripts" "$ROOT/lib" \
-name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true
for test in "$ROOT"/tests/test-*.sh; do
[[ -x "$test" ]] || continue
echo "Running $(basename "$test")"
"$test" > /dev/null || {
echo "Tests failed. Run it directly to see why:"
echo " $test"
exit 1
}
done
echo "Building Ekklesia Classroom ${VERSION}"
# Always construct the package from clean canonical sources.