#!/bin/bash set -euo pipefail # Puts a notification on the classroom screen, from anywhere. # # classroom-notify "Title" "Body text" # classroom-notify --critical "Parent Call" "Room 3 needs a parent." # classroom-notify --urgency=low "DVD" "Disc ejected." # sudo classroom-notify --dry-run # # Used by classroom-notify-test to send a sample, and by classroom-mqtt # to put messages from the church broker on the screen. # # The classroom desktop runs as the classroom user, and the notification # service is part of that user's Plasma 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 service that 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 while [[ $# -gt 0 ]]; do case "$1" in --critical) urgency=critical shift ;; --low) urgency=low shift ;; --urgency=*) urgency="${1#--urgency=}" case "$urgency" in low|normal|critical) ;; *) urgency=normal ;; esac shift ;; --dry-run) dry_run=true shift ;; --) shift break ;; -*) echo "classroom-notify: 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.}" fail() { echo "classroom-notify: $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 } # The compositor 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. session_pid="n/a" runtime_dir="${XDG_RUNTIME_DIR:-}" bus="$DBUS_SESSION_BUS_ADDRESS" wayland_display="$WAYLAND_DISPLAY" else session="the $CLASSROOM_USER desktop session" # The compositor is the session: it owns the Wayland display and the # environment every other part of the session inherited. session_pid="$(pgrep -u "$CLASSROOM_USER" -x kwin_wayland | head -n 1 || true)" [[ -n "$session_pid" ]] || fail "no classroom desktop session is running: there is no kwin_wayland 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) or as the $CLASSROOM_USER user." fi environment_block="$(read_environ "$session_pid")" if [[ -n "$environment_block" ]]; then source_note="read from the compositor (pid $session_pid)" else source_note="the compositor'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 "$session_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 notification service that is already running. Sending into # a session without one would have D-Bus start something with no display to # draw on, which is exactly the failure this script exists to avoid. pgrep -u "$CLASSROOM_USER" -x plasmashell >/dev/null 2>&1 || fail "the classroom shell is not running, and this will not start it. Check with: pgrep -u $CLASSROOM_USER -a plasmashell" if [[ "$dry_run" == true ]]; then echo "Would notify $session:" echo " compositor pid: $session_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="Classroom" \ --urgency="$urgency" \ -- \ "$title" "$body" echo "Sent $urgency notification to $session: $title"