Files
cachy-auto-update/src/lib/progress.sh
T
Felitendo 6aa9b147b8 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.
2026-08-14 10:49:02 +02:00

268 lines
9.2 KiB
Bash

# shellcheck shell=bash
#
# The update's progress bar on the desktop.
#
# An unattended upgrade can take twenty minutes, and for most of that a user is
# told only that "an update is running". This drives the desktop's job list -
# the same widget that shows a bar while Dolphin copies files - so how far
# along the run is stays visible the whole time.
#
# The desktop ends the progress entry as soon as the D-Bus connection that
# asked for it goes away, which no one-shot bus client can survive. So an
# actual process per session holds that connection open and takes instructions
# on stdin; see cachy-auto-update-progress. Everything below is the writing end
# of those pipes, plus the arithmetic that turns "package 120 of 260 in the
# repository step" into one number for the bar.
#
# Absent anywhere along the way - no session, no Plasma, no Python bindings -
# this does nothing at all and the update proceeds exactly as before.
CAU_PROGRESS_HELPER="${CAU_LIBEXECDIR}/cachy-auto-update-progress"
# One entry per session being driven; the indices line up across all four.
CAU_PROGRESS_FDS=()
CAU_PROGRESS_PIDS=()
CAU_PROGRESS_FIFOS=()
CAU_PROGRESS_LOCALES=()
# What each step is worth on the bar. Rough shares of a typical run rather than
# anything measured: repositories dominate, the cleanup is a rounding error.
# They do not have to add up to 100 - only the steps a given run will actually
# perform are counted, and the total is normalised against those.
declare -A CAU_PROGRESS_WEIGHTS=(
[repo]=70 [aur]=15 [flatpak]=10 [appimage]=3 [cleanup]=2
)
CAU_PROGRESS_PLAN=()
CAU_PROGRESS_SCALE=0
CAU_PROGRESS_BASE=0
CAU_PROGRESS_SPAN=0
CAU_PROGRESS_TOTAL=0
CAU_PROGRESS_SHOWN=-1
# _cau_progress_send <line>
# The same instruction to every session. A session whose helper has exited is
# dropped rather than written to: the runner writes into a pipe, and a pipe
# nobody is draining fills up and would eventually block the update itself.
_cau_progress_send() {
local i fd
for i in "${!CAU_PROGRESS_FDS[@]}"; do
fd="${CAU_PROGRESS_FDS[$i]}"
[[ -n $fd ]] || continue
if ! kill -0 "${CAU_PROGRESS_PIDS[$i]}" 2>/dev/null; then
CAU_PROGRESS_FDS[$i]=''
continue
fi
printf '%s\n' "$1" >&"$fd" 2>/dev/null || CAU_PROGRESS_FDS[$i]=''
done
}
# _cau_progress_line <format> [printf args...]
# Assembled with printf -v rather than in a command substitution: this is on
# the per-package path of a large upgrade, and a fork per line is a fork too
# many for something whose entire job is to be unobtrusive.
_cau_progress_line() {
local line
# shellcheck disable=SC2059 # the format is ours; the arguments are numbers
printf -v line "$@"
_cau_progress_send "$line"
}
# cau_progress_begin <step-id...>
# Opens the progress entry in every graphical session, and records which steps
# this run is going to perform so the bar can be scaled to them.
cau_progress_begin() {
local user uid fifo pid
local fd=''
CAU_PROGRESS_PLAN=("$@")
CAU_PROGRESS_SCALE=0
local step
for step in "${CAU_PROGRESS_PLAN[@]}"; do
CAU_PROGRESS_SCALE=$(( CAU_PROGRESS_SCALE + ${CAU_PROGRESS_WEIGHTS[$step]:-0} ))
done
(( CAU_PROGRESS_SCALE > 0 )) || return 0
[[ $CFG_NOTIFICATIONS == yes ]] || return 0
[[ -x $CAU_PROGRESS_HELPER ]] || return 0
mkdir -p "$CAU_RUNDIR" 2>/dev/null || return 0
while read -r user uid; do
[[ -n $user ]] || continue
# The helper speaks D-Bus through GLib's Python bindings. Checked here
# rather than left to fail inside the helper, because a helper that
# gave up immediately would leave nobody draining the pipe.
cau_as_user "$user" "$uid" sh -c \
'command -v python3 >/dev/null 2>&1 && python3 -c "import gi" 2>/dev/null' \
|| continue
fifo="${CAU_RUNDIR}/progress.${uid}"
rm -f "$fifo" 2>/dev/null
mkfifo -m 0600 "$fifo" 2>/dev/null || continue
chown "$uid" "$fifo" 2>/dev/null || true
cau_as_user "$user" "$uid" "$CAU_PROGRESS_HELPER" < "$fifo" > /dev/null 2>&1 &
pid=$!
# Read-write deliberately. Opening the writing end of a fifo blocks
# until a reader shows up, so if the helper died on the way in, the
# update would hang here for good. O_RDWR never blocks, and the helper
# still sees end-of-file once this descriptor is closed.
if ! exec {fd}<> "$fifo"; then
kill "$pid" 2>/dev/null
rm -f "$fifo" 2>/dev/null
continue
fi
CAU_PROGRESS_FDS+=("$fd")
CAU_PROGRESS_PIDS+=("$pid")
CAU_PROGRESS_FIFOS+=("$fifo")
CAU_PROGRESS_LOCALES+=("$(cau_user_locale "$user" "$uid")")
done < <(cau_active_session_users)
}
# cau_progress_active
# Whether anybody is listening. For callers that would otherwise do work whose
# only purpose is to feed the bar.
cau_progress_active() {
(( ${#CAU_PROGRESS_FDS[@]} ))
}
# cau_progress_step <step-id> <label-msgid> [item-count]
# Moves on to the next step. The bar jumps to where that step begins, so a step
# that reported fewer items than it promised still completes rather than
# leaving a gap.
cau_progress_step() {
local id="$1" label="$2" total="${3:-0}"
local step i fd base=0
(( ${#CAU_PROGRESS_FDS[@]} )) || return 0
for step in "${CAU_PROGRESS_PLAN[@]}"; do
[[ $step == "$id" ]] && break
base=$(( base + ${CAU_PROGRESS_WEIGHTS[$step]:-0} ))
done
CAU_PROGRESS_BASE=$base
CAU_PROGRESS_SPAN=${CAU_PROGRESS_WEIGHTS[$id]:-0}
CAU_PROGRESS_TOTAL=$total
# The label is the one line a user actually reads, so it is rendered in
# each session's own locale rather than the run's C locale.
for i in "${!CAU_PROGRESS_FDS[@]}"; do
fd="${CAU_PROGRESS_FDS[$i]}"
[[ -n $fd ]] || continue
cau_msg_into "${CAU_PROGRESS_LOCALES[$i]}" "$label"
printf 'info\t%s\n' "$CAU_MSG_RESULT" >&"$fd" 2>/dev/null || true
done
# Unconditionally, including the zero case: a step with no item count of
# its own would otherwise keep displaying the previous step's tally, and
# "260 of 260 items" under the heading "Flatpaks" is worse than no count.
_cau_progress_line 'total\t%s' "$total"
_cau_progress_line 'done\t%s' 0
cau_progress_item 0
}
# cau_progress_item <processed> [total]
# How far through the current step we are.
cau_progress_item() {
local processed="$1" total="${2:-$CAU_PROGRESS_TOTAL}" pct scaled
(( ${#CAU_PROGRESS_FDS[@]} )) || return 0
[[ $processed =~ ^[0-9]+$ ]] || return 0
if [[ $total =~ ^[0-9]+$ ]] && (( total > 0 )); then
(( processed > total )) && processed=$total
if (( total != CAU_PROGRESS_TOTAL )); then
CAU_PROGRESS_TOTAL=$total
_cau_progress_line 'total\t%s' "$total"
fi
_cau_progress_line 'done\t%s' "$processed"
scaled=$(( CAU_PROGRESS_BASE * 100 + CAU_PROGRESS_SPAN * 100 * processed / total ))
else
scaled=$(( CAU_PROGRESS_BASE * 100 ))
fi
pct=$(( scaled / CAU_PROGRESS_SCALE ))
(( pct > 100 )) && pct=100
# Only when the whole number changes. Percent is the one field the runner
# would otherwise rewrite for every package on a 500-package upgrade.
(( pct == CAU_PROGRESS_SHOWN )) && return 0
CAU_PROGRESS_SHOWN=$pct
_cau_progress_line 'percent\t%s' "$pct"
}
# cau_progress_detail <label-msgid> <value>
# A labelled line under the entry's "Details" - which package is being unpacked
# right now, say.
cau_progress_detail() {
local label="$1" value="$2" i fd
(( ${#CAU_PROGRESS_FDS[@]} )) || return 0
for i in "${!CAU_PROGRESS_FDS[@]}"; do
fd="${CAU_PROGRESS_FDS[$i]}"
[[ -n $fd ]] || continue
cau_msg_into "${CAU_PROGRESS_LOCALES[$i]}" "$label"
printf 'detail\t%s\t%s\n' "$CAU_MSG_RESULT" "$value" >&"$fd" 2>/dev/null || true
done
}
# cau_progress_end [outcome: ok|failed] [failure-msgid]
# Closes the entry. Must run on every exit path, including a killed run: an
# entry whose owner merely vanishes is reported by the desktop as "the
# application closed unexpectedly", which would end every update with a failure
# notice. Safe to call twice, and safe to call when nothing was ever opened.
#
# ok the bar fills and the entry goes away
# failed the entry goes away from wherever the bar had got to
# failed <msgid> and the desktop labels it as failed, with that text
#
# The distinction between the last two is which message the user ends up with.
# An ordinary failure already sends a notification that stays until dismissed,
# and two messages about one problem is one too many; a run that was killed
# sends nothing at all, so there the label is the only thing that explains why
# a bar that was at 40% is suddenly gone.
cau_progress_end() {
local outcome="${1:-ok}" msgid="${2:-}"
local i fd
# The last step never consumes its own share - nothing reports items for
# the cleanup - so the bar would stop a few percent short of the end and
# vanish there. Only on the way out of a run that actually worked, though:
# filling the bar for a failed update says the opposite of what happened.
[[ $outcome == ok ]] && _cau_progress_line 'percent\t100'
for i in "${!CAU_PROGRESS_FDS[@]}"; do
fd="${CAU_PROGRESS_FDS[$i]}"
[[ -n $fd ]] || continue
CAU_MSG_RESULT=''
[[ -n $msgid ]] && cau_msg_into "${CAU_PROGRESS_LOCALES[$i]}" "$msgid"
printf 'end\t%s\n' "$CAU_MSG_RESULT" >&"$fd" 2>/dev/null || true
exec {fd}>&-
done
for i in "${!CAU_PROGRESS_PIDS[@]}"; do
wait "${CAU_PROGRESS_PIDS[$i]}" 2>/dev/null
done
for i in "${!CAU_PROGRESS_FIFOS[@]}"; do
rm -f "${CAU_PROGRESS_FIFOS[$i]}" 2>/dev/null
done
CAU_PROGRESS_FDS=()
CAU_PROGRESS_PIDS=()
CAU_PROGRESS_FIFOS=()
CAU_PROGRESS_LOCALES=()
CAU_PROGRESS_SHOWN=-1
}