Files
cachy-auto-update/src/lib/notify.sh
T

202 lines
7.4 KiB
Bash

# shellcheck shell=bash
#
# Desktop notifications from a root system service.
#
# Two paths exist:
# * live: somebody has a graphical session, so notify-send is run inside
# it via runuser with the session bus address set;
# * queued: nobody is logged in, so the message is appended to a spool that
# the XDG autostart entry replays at the next login.
#
# Messages travel as a msgid plus printf arguments rather than as finished
# text, so a notification queued at 04:00 is still rendered in whatever locale
# the user's desktop turns out to be running in.
CAU_NOTIFY_ICON="system-software-update"
CAU_NOTIFY_QUEUE_MAX=20
# cau_notify <urgency> <linger: yes|no> <title-msgid> <body-msgid> [args...]
# Never fails: a machine without libnotify, or with nobody logged in, is a
# normal state, not an error.
cau_notify() {
cau_notify_tagged '' yes "$@"
}
# cau_notify_close <user> <uid> <id>
# notify-send can create and replace notifications but not withdraw one, so
# this goes to the bus directly. gdbus comes from glib2, which libnotify itself
# links against, so it is present wherever notify-send is.
cau_notify_close() {
cau_as_user "$1" "$2" gdbus call --session \
--dest org.freedesktop.Notifications \
--object-path /org/freedesktop/Notifications \
--method org.freedesktop.Notifications.CloseNotification \
"$3" > /dev/null 2>&1 || true
}
# cau_notify_tagged <tag> <queue: yes|no> <urgency> <linger: yes|no> \
# <title> <body> [args...]
#
# linger=yes keeps the message on screen until somebody dismisses it; anything
# else lets it time out on its own. The line it draws is whether the machine
# still needs a person: a finished update is over and done with and should not
# have to be clicked away, while a failure, a paused queue or a package that
# had to be skipped is only ever seen if it waits.
#
# Set explicitly rather than left to the server. Notification daemons do keep
# critical-urgency messages up (the spec asks them to, and Plasma obliges).
# But that is a "should", it says nothing about the normal-urgency messages
# here that still need somebody to act, and urgency separately controls sound
# and whether do-not-disturb is overridden. Those are not the same question.
#
# A tag means "at most one bubble of this kind on screen at a time": the
# previous one carrying the same tag is withdrawn first, so "installing
# updates" gives way to "system updated" instead of leaving two messages that
# contradict each other.
#
# Withdraw-then-post rather than the obvious --replace-id, because replacing
# only works while the old bubble is still on screen. Plasma's server drops a
# Notify() whose replaces_id names an expired notification: no bubble, no
# error, and the id it hands back is the dead one it just ignored. An update
# run lasts minutes and the start bubble times out after seconds, so the
# finished message landed in exactly that hole and was never seen. Closing an
# id that is already gone is a no-op, which makes this safe either way.
#
# queue=no is for messages that only mean anything while somebody is looking.
# Telling a user at next login that an update started an hour ago is noise.
cau_notify_tagged() {
local tag="$1" queue="$2" urgency="$3" linger="$4" title="$5" body="$6"
shift 6
local -a args=("$@")
local delivered=0 user uid locale t b prev newid
[[ $CFG_NOTIFICATIONS == yes ]] || return 0
# -1 is "whatever the server thinks", which is what a message nobody has to
# act on wants; 0 is "until dismissed".
local -a expiry=(--expire-time=-1)
[[ $linger == yes ]] && expiry=(--expire-time=0)
while read -r user uid; do
[[ -n $user ]] || continue
cau_as_user "$user" "$uid" sh -c 'command -v notify-send >/dev/null' || continue
locale="$(cau_user_locale "$user" "$uid")"
t="$(cau_msg_in "$locale" "$title")"
b="$(cau_msg_in "$locale" "$body" "${args[@]}")"
if [[ -n $tag ]]; then
prev="$(cau_state_read "notify_id_${tag}_${user}" 0)"
if [[ $prev =~ ^[0-9]+$ ]] && (( prev > 0 )); then
cau_notify_close "$user" "$uid" "$prev"
fi
cau_state_clear "notify_id_${tag}_${user}"
fi
if newid="$(cau_as_user "$user" "$uid" notify-send \
--app-name="$CAU_PRETTY" \
--icon="$CAU_NOTIFY_ICON" \
--urgency="$urgency" \
"${expiry[@]}" \
--print-id \
-- "$t" "$b" 2>/dev/null)"
then
delivered=1
if [[ -n $tag && $newid =~ ^[0-9]+$ ]]; then
cau_state_write "notify_id_${tag}_${user}" "$newid"
fi
fi
done < <(cau_active_session_users)
(( delivered )) && return 0
[[ $queue == yes ]] || return 0
cau_notify_enqueue "$urgency" "$linger" "$title" "$body" "${args[@]}"
}
# cau_notify_enqueue <urgency> <linger> <title-msgid> <body-msgid> [args...]
# Tab-separated records, oldest first. The file is world-readable on purpose:
# the login-time delivery runs unprivileged and only ever reads it.
#
# The key is a nanosecond timestamp rather than a second one: delivery marks
# progress by "last key seen", so two records sharing a key could make the
# second one unreachable forever if a login landed between them.
cau_notify_enqueue() {
local urgency="$1" linger="$2" title="$3" body="$4"
shift 4
local record tmp
mkdir -p "$CAU_STATEDIR" 2>/dev/null || return 0
record="$(date +%s%N)"$'\t'"$urgency"$'\t'"$linger"$'\t'"$title"$'\t'"$body"
local arg
for arg in "$@"; do
record+=$'\t'"${arg//$'\t'/ }"
done
printf '%s\n' "$record" >> "$CAU_NOTIFY_QUEUE" 2>/dev/null || return 0
# keep the spool bounded: nobody wants three weeks of backlog at login
if (( $(wc -l < "$CAU_NOTIFY_QUEUE" 2>/dev/null || echo 0) > CAU_NOTIFY_QUEUE_MAX )); then
tmp="$(mktemp "${CAU_NOTIFY_QUEUE}.XXXXXX")" || return 0
tail -n "$CAU_NOTIFY_QUEUE_MAX" "$CAU_NOTIFY_QUEUE" > "$tmp"
mv -f "$tmp" "$CAU_NOTIFY_QUEUE"
fi
chmod 0644 "$CAU_NOTIFY_QUEUE" 2>/dev/null || true
}
# cau_notify_deliver_queue
# Runs unprivileged, as the freshly logged-in user, from the XDG autostart
# entry. State about what has already been seen lives in the user's own home,
# so no write access to /var/lib is needed and each user is tracked separately.
cau_notify_deliver_queue() {
local seen_file seen ts urgency linger title body rest
local -a args expiry
[[ -r $CAU_NOTIFY_QUEUE ]] || return 0
cau_have notify-send || return 0
seen_file="${XDG_STATE_HOME:-$HOME/.local/state}/cachy-auto-update/notify-seen"
mkdir -p "$(dirname "$seen_file")" 2>/dev/null || return 0
seen=0
[[ -r $seen_file ]] && seen="$(< "$seen_file")"
[[ $seen =~ ^[0-9]+$ ]] || seen=0
local newest="$seen"
while IFS=$'\t' read -r ts urgency linger title body rest; do
[[ $ts =~ ^[0-9]+$ ]] || continue
(( ts > seen )) || continue
# Records spooled before the linger field existed have the title where
# the flag now sits. Shift them back rather than announcing an update
# under the headline "yes".
if [[ $linger != yes && $linger != no ]]; then
rest="${body}${rest:+$'\t'}${rest:-}"
body="$title"
title="$linger"
linger=no
fi
# remaining tab-separated fields are the body's printf arguments
args=()
if [[ -n ${rest:-} ]]; then
IFS=$'\t' read -r -a args <<< "$rest"
fi
expiry=(--expire-time=-1)
[[ $linger == yes ]] && expiry=(--expire-time=0)
notify-send \
--app-name="$CAU_PRETTY" \
--icon="$CAU_NOTIFY_ICON" \
--urgency="${urgency:-normal}" \
"${expiry[@]}" \
-- "$(cau_msg "$title")" "$(cau_msg "$body" "${args[@]}")" 2>/dev/null || true
(( ts > newest )) && newest="$ts"
done < "$CAU_NOTIFY_QUEUE"
printf '%s\n' "$newest" > "$seen_file" 2>/dev/null || true
}