Put a progress bar on the desktop, and repair the notifications around it

An unattended run takes twenty minutes and said nothing at all while it worked.
The notification area now carries a live entry for the duration - which step is
running, which package is being unpacked, item count, percentage. It is a job
in the sense of org.kde.JobViewServer, the same mechanism Dolphin uses while
copying files, rather than a notification, which is what makes it a real bar
instead of a line of text.

That mechanism needs a process of its own. The desktop ties a job to the D-Bus
connection that requested it and withdraws it the moment that connection
closes, so gdbus, busctl and dbus-send cannot drive one at all - every
invocation is a fresh connection that closes again immediately. A small helper
therefore runs inside each graphical session for the length of the update,
holding the connection open and taking instructions on stdin. It needs
python-gobject; without it there is no bar and nothing else changes. Position
within the repository step is read from pacman's own "(120/260) upgrading foo"
lines. Its other (n/m) sequences are ignored on purpose: checking keys, package
integrity and loading files each count up to the same total, and following them
would run the bar to the end three times before the first package was unpacked.

The "system updated" notification never arrived, which is what prompted looking
at any of this. Tagged notifications were posted with --replace-id naming the
start message, and Plasma silently drops a Notify() whose replaces_id points at
an expired notification: no bubble, no error, and the id it hands back is the
dead one it just ignored. The start bubble times out in seconds and a run lasts
minutes, so the result fell into exactly that hole every single time. The
previous message is now withdrawn and a fresh one posted.

How long a message stays is set per message rather than left to the daemon.
Anything reporting that the machine still needs a person - a failed update, a
locked package database, packages that had to be held back - waits until it is
dismissed. Everything else times out on its own, a successful update included;
nothing should have to be clicked away for having gone right. Daemons do keep
critical-urgency messages up and the specification asks them to, but that is a
should, it says nothing about the normal-urgency messages here that still need
somebody to act, and urgency separately governs sound and do-not-disturb.

"Restart recommended" is gone, and NotifyReboot with it. The running kernel
loses its module tree the moment pacman unpacks the new one, so the notice
fired while the run was still building AUR packages and pulling Flatpaks, where
it reads as an invitation to restart in the middle of an update. The state is
still recorded and cachy-auto-update status still reports it. The option went
rather than only its default, because an installed system keeps its own
configuration file and would have carried on notifying regardless.
This commit is contained in:
Felitendo committed 2026-08-14 10:49:02 +02:00
1 parent a17aed4b3d
commit 6aa9b147b8
17 files changed
+789 -68

No files matched your search

+74 -19
View File
@@ -15,30 +15,68 @@
CAU_NOTIFY_ICON="system-software-update"
CAU_NOTIFY_QUEUE_MAX=20
# cau_notify <urgency> <title-msgid> <body-msgid> [body printf args...]
# 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_tagged <tag> <queue: yes|no> <urgency> <title> <body> [args...]
# 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...]
#
# A tag makes this notification replace the previous one carrying the same tag
# rather than stacking a second bubble beside it - that is what turns
# "installing updates" into "system updated" in place instead of leaving two
# messages that contradict each other.
# 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" title="$4" body="$5"
shift 5
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
@@ -47,17 +85,20 @@ cau_notify_tagged() {
t="$(cau_msg_in "$locale" "$title")"
b="$(cau_msg_in "$locale" "$body" "${args[@]}")"
prev=0
if [[ -n $tag ]]; then
prev="$(cau_state_read "notify_id_${tag}_${user}" 0)"
[[ $prev =~ ^[0-9]+$ ]] || prev=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" \
--print-id --replace-id="$prev" \
"${expiry[@]}" \
--print-id \
-- "$t" "$b" 2>/dev/null)"
then
delivered=1
@@ -70,10 +111,10 @@ cau_notify_tagged() {
(( delivered )) && return 0
[[ $queue == yes ]] || return 0
cau_notify_enqueue "$urgency" "$title" "$body" "${args[@]}"
cau_notify_enqueue "$urgency" "$linger" "$title" "$body" "${args[@]}"
}
# cau_notify_enqueue <urgency> <title-msgid> <body-msgid> [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.
#
@@ -81,13 +122,13 @@ cau_notify_tagged() {
# 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" title="$2" body="$3"
shift 3
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'"$title"$'\t'"$body"
record="$(date +%s%N)"$'\t'"$urgency"$'\t'"$linger"$'\t'"$title"$'\t'"$body"
local arg
for arg in "$@"; do
record+=$'\t'"${arg//$'\t'/ }"
@@ -109,8 +150,8 @@ cau_notify_enqueue() {
# 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 title body
local -a args
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
@@ -123,20 +164,34 @@ cau_notify_deliver_queue() {
[[ $seen =~ ^[0-9]+$ ]] || seen=0
local newest="$seen"
while IFS=$'\t' read -r ts urgency title body rest; do
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"