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

+16 -1
View File
@@ -8,7 +8,7 @@
# Overridable so a packager can pass the version it is actually building
# (`make VERSION=$pkgver`). The literal below is the fallback for builds
# straight from a checkout, and is what a release tag has to carry.
VERSION ?= 1.1.2
VERSION ?= 1.2.0
PREFIX ?= /usr
DESTDIR ?=
@@ -64,6 +64,13 @@ check:
else \
echo "shellcheck not found - skipped"; \
fi
@if command -v python3 >/dev/null 2>&1; then \
python3 -m py_compile src/cachy-auto-update-progress \
&& echo "ok src/cachy-auto-update-progress"; \
rm -rf src/__pycache__; \
else \
echo "python3 not found - skipped"; \
fi
@if command -v visudo >/dev/null 2>&1; then \
visudo -cf res/sudoers/cachy-auto-update >/dev/null && echo "ok sudoers"; \
fi
@@ -72,6 +79,9 @@ install: build
# executables
install -Dm755 src/cachy-auto-update "$(DESTDIR)$(BINDIR)/cachy-auto-update"
install -Dm755 src/cachy-auto-update-run "$(DESTDIR)$(LIBEXECDIR)/cachy-auto-update-run"
# holds a session bus connection open so the update can drive a progress bar
install -Dm755 src/cachy-auto-update-progress \
"$(DESTDIR)$(LIBEXECDIR)/cachy-auto-update-progress"
# the version and the resolved lib path are baked in at install time
sed -i -e 's|@VERSION@|$(VERSION)|g' \
-e 's|@LIBDIR@|$(LIBDIR)|g' \
@@ -114,6 +124,10 @@ install: build
install -Dm644 res/autostart/cachy-auto-update-notify.desktop \
"$(DESTDIR)$(XDGAUTOSTART)/cachy-auto-update-notify.desktop"
# names and illustrates the progress bar; hidden from the application menu
install -Dm644 res/applications/cachy-auto-update.desktop \
"$(DESTDIR)$(DATADIR)/applications/cachy-auto-update.desktop"
# translations
@for l in $(LINGUAS); do \
if [ -f "po/$$l.mo" ]; then \
@@ -138,6 +152,7 @@ uninstall:
rm -f "$(DESTDIR)$(SYSTEMDDIR)/cachy-auto-update.service"
rm -f "$(DESTDIR)$(SYSTEMDDIR)/cachy-auto-update.timer"
rm -f "$(DESTDIR)$(XDGAUTOSTART)/cachy-auto-update-notify.desktop"
rm -f "$(DESTDIR)$(DATADIR)/applications/cachy-auto-update.desktop"
rm -f "$(DESTDIR)$(MANDIR)/man1/cachy-auto-update.1"
clean:
+65 -7
View File
@@ -83,8 +83,11 @@ A run is postponed — and retried an hour later — when:
`cachy-auto-update status` prints every one of these individually, which is the
fastest way to find out why nothing is happening.
The machine is **never** restarted on its own. When a kernel update needs a
restart, you get a notification saying so.
The machine is **never** restarted on its own. A kernel update that needs a
restart is reported by `cachy-auto-update status`, not by a notification: the
running kernel loses its module tree the moment pacman unpacks the new one, so
a bubble would arrive while the run is still building AUR packages and pulling
Flatpaks — and reads as an invitation to restart in the middle of it.
## About the password question
@@ -134,8 +137,15 @@ Three layers, in order of how much they can actually promise:
**A notification goes out before the transaction starts** — "Installing
updates, please leave the computer switched on until this is done" — and is
replaced in place by the result when the run finishes, so it costs one bubble
rather than two. This exists because of what the next paragraph does *not* do.
withdrawn again when the result arrives, so it costs one bubble rather than
two. This exists because of what the next paragraph does *not* do.
**A progress bar sits in the notification area for the whole run**, the same
one Dolphin puts there while it copies files: which step is running, which
package is being unpacked, how far along the whole thing is. A twenty-minute
run that shows nothing looks indistinguishable from a hung one, and that is
what gets a machine switched off in the middle of a transaction. See
[The progress bar](#the-progress-bar).
**Suspend and a normal shutdown are blocked.** The run holds a
`systemd-inhibit --what=sleep:shutdown --mode=block` lock, so closing the lid or
@@ -170,6 +180,49 @@ On a Btrfs system with `snapper` and `snap-pac` — the CachyOS default — ever
pacman transaction is bracketed by a pre and post snapshot, so a genuinely
broken upgrade can still be rolled back with `snapper rollback`.
## How long notifications stay
A message that means the machine still needs you — an update failed, the
package database is locked, packages had to be held back — **stays until you
dismiss it**. That kind of message is only worth sending if it is still there
when you come back to the machine.
Everything else times out on its own, a successful update included. Nothing
should have to be clicked away for having gone right.
Set per message rather than left to the notification daemon. Daemons do keep
critical-urgency messages up and the spec 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 controls sound and do-not-disturb bypass — a
different question. Queued messages delivered at the next login keep the same
distinction.
## The progress bar
While a run is working, the notification area carries a live entry — headline,
item count, percentage, and the package currently being unpacked under
*Details*. It is not a notification but a **job**, the same mechanism Dolphin
uses for file copies, which is what gets you a bar rather than a line of text.
Two things about how it is put together:
- The desktop ties a job to the D-Bus connection that asked for it, and
withdraws the job the moment that connection closes. One-shot bus clients —
`gdbus`, `busctl`, `dbus-send` — therefore cannot drive one at all, since
every invocation is a fresh connection that closes immediately. So a small
helper (`cachy-auto-update-progress`) 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 simply no bar and
nothing else changes.
- The position inside the repository step comes from pacman's own
`(120/260) upgrading foo` lines. pacman's other `(n/m)` sequences — checking
keys, package integrity, loading files — each count to the same total, so
only the transaction verbs are followed; otherwise the bar would reach the
end three times before the first package was unpacked.
This is Plasma's job interface. On a desktop that does not implement it the
helper exits quietly and the ordinary notifications carry on as before.
## Configuration
`/etc/cachy-auto-update/cachy-auto-update.conf`, one `Key=Value` per line, every
@@ -203,7 +256,7 @@ Two things are deliberately *not* automated:
- **File conflicts** (`exists in filesystem`) — forcing `--overwrite` could
silently destroy something that was put there on purpose.
- **Reboots** — you get a notification, never a surprise restart.
- **Reboots** — `status` tells you one is due, never a surprise restart.
Signature failures trigger one keyring refresh and one retry, since a stale
keyring blocks everything else until it is fixed.
@@ -226,8 +279,13 @@ sudo systemd-sysusers && sudo systemd-tmpfiles --create
sudo cachy-auto-update enable
```
`make check` runs `bash -n` over everything, `shellcheck` when available, and
validates the sudoers drop-in with `visudo -c`.
`make check` runs `bash -n` over every shell file, `py_compile` over the
progress helper, `shellcheck` when available, and validates the sudoers drop-in
with `visudo -c`.
Everything is optional at runtime and degrades to doing less rather than
failing: `pacman-contrib` for `checkupdates`, an AUR helper, `flatpak`, Gear
Lever, `libnotify` for notifications, and `python-gobject` for the progress bar.
## Relationship to cachy-update
+47 -4
View File
@@ -100,14 +100,57 @@ inside each user's own account. AppImages are updated through Gear Lever, for
users with a graphical session, and AppImages whose application is currently
running are skipped.
The machine is never restarted automatically. When a kernel update makes a
restart necessary, a notification says so.
The machine is never restarted automatically. A kernel update that makes a
restart necessary is reported by *cachy-auto-update status*, not by a
notification: the running kernel loses its module tree as soon as pacman
unpacks the new one, so a bubble would fire while the run is still working
through AUR packages and Flatpaks, and reads as an invitation to restart in the
middle of it.
# PROGRESS
For as long as a run is working, the notification area carries a live progress
entry: the step being performed, how many items it has got through, an overall
percentage, and the package currently being unpacked under "Details". This is a
job in the sense of *org.kde.JobViewServer*, the same mechanism a file manager
uses while copying, rather than a notification - which is what makes it a bar
instead of a line of text.
The desktop withdraws a job as soon as the D-Bus connection that requested it
closes, so a helper process runs inside each graphical session for the duration
of the update and holds that connection open. It requires _python-gobject_.
Where that is missing, or on a desktop with no job interface, there is no
progress entry and nothing else is affected.
The position within the repository step is read from pacman's own
"(120/260) upgrading foo" output. Its other (n/m) sequences - checking keys,
package integrity, loading package files - each count up to the same total and
are deliberately ignored.
# HOW LONG NOTIFICATIONS STAY
A message that reports the machine still needing a person - an update that
failed, a package database left locked, packages that had to be held back -
stays on screen until it is dismissed. A message like that is only worth
sending if it is still there when somebody comes back to the machine.
Everything else times out by itself, an update that simply worked included.
Nothing should have to be clicked away for having gone right.
This is set per message rather than left to the notification daemon. Daemons do
keep critical-urgency messages up, and the specification asks them to, but that
is a recommendation, it does not cover the normal-urgency messages here that
still need somebody to act, and urgency separately governs sound and whether
do-not-disturb is overridden.
Where nobody is logged in the message is spooled and delivered at the next
login, with the same distinction preserved.
# INTERRUPTED UPDATES
Before a transaction starts, a notification says an update is running and asks
for the machine to be left on. It is replaced in place by the result once the
run finishes.
for the machine to be left on. It is withdrawn again when the result arrives,
so one bubble is used rather than two.
While a transaction is running, *cachy-auto-update* holds a
*systemd-inhibit*(1) lock on _sleep_ and _shutdown_ in blocking mode, so a
+22 -9
View File
@@ -218,6 +218,28 @@ msgstr ""
msgid "Without a command an interactive menu is shown."
msgstr ""
#. Progress bar
msgid "Repository packages"
msgstr ""
msgid "AUR packages"
msgstr ""
msgid "Flatpaks"
msgstr ""
msgid "AppImages"
msgstr ""
msgid "Cleaning up"
msgstr ""
msgid "Package"
msgstr ""
msgid "The update was stopped before it finished."
msgstr ""
#. Notifications
msgid "System updated"
msgstr ""
@@ -232,12 +254,6 @@ msgstr ""
msgid "Something went wrong while updating. Run 'cachy-auto-update log' for details."
msgstr ""
msgid "Restart recommended"
msgstr ""
msgid "A new kernel was installed. Please restart when it suits you."
msgstr ""
msgid "Package database locked"
msgstr ""
@@ -291,9 +307,6 @@ msgstr ""
msgid "Notify when something goes wrong"
msgstr ""
msgid "Notify when a restart is needed"
msgstr ""
msgid "Time between update runs"
msgstr ""
+22 -9
View File
@@ -219,6 +219,28 @@ msgstr "Version anzeigen"
msgid "Without a command an interactive menu is shown."
msgstr "Ohne Befehl wird ein interaktives Menü angezeigt."
#. Progress bar
msgid "Repository packages"
msgstr "Paketquellen"
msgid "AUR packages"
msgstr "AUR-Pakete"
msgid "Flatpaks"
msgstr "Flatpaks"
msgid "AppImages"
msgstr "AppImages"
msgid "Cleaning up"
msgstr "Wird aufgeräumt"
msgid "Package"
msgstr "Paket"
msgid "The update was stopped before it finished."
msgstr "Das Update wurde vor dem Ende abgebrochen."
#. Notifications
msgid "System updated"
msgstr "System aktualisiert"
@@ -233,12 +255,6 @@ msgstr "Update fehlgeschlagen"
msgid "Something went wrong while updating. Run 'cachy-auto-update log' for details."
msgstr "Beim Update ist etwas schiefgelaufen. Details mit „cachy-auto-update log“."
msgid "Restart recommended"
msgstr "Neustart empfohlen"
msgid "A new kernel was installed. Please restart when it suits you."
msgstr "Es wurde ein neuer Kernel installiert. Bitte bei Gelegenheit neu starten."
msgid "Package database locked"
msgstr "Paketdatenbank gesperrt"
@@ -293,9 +309,6 @@ msgstr "Melden nach erfolgreichem Update"
msgid "Notify when something goes wrong"
msgstr "Melden, wenn etwas schiefgeht"
msgid "Notify when a restart is needed"
msgstr "Melden, wenn ein Neustart nötig ist"
msgid "Time between update runs"
msgstr "Abstand zwischen Update-Läufen"
@@ -0,0 +1,14 @@
[Desktop Entry]
# Not here to put an entry in the application menu - hence NoDisplay. This is
# how the desktop learns what to call the update while it runs: the progress
# bar in the notification area is labelled from the desktop entry named in the
# job request, and without one the job is filed under whatever the helper
# process happens to be called.
Type=Application
Name=CachyOS Auto-Update
Comment=Unattended background updates
Exec=cachy-auto-update
Icon=system-software-update
Terminal=true
Categories=System;PackageManager;
NoDisplay=true
+18 -5
View File
@@ -94,11 +94,21 @@ RemoveOrphans=no
# ---------------------------------------------------------------------------
# Notification detail
# ---------------------------------------------------------------------------
#
# How long a message stays on screen is not configurable, because it follows
# from what the message is for: anything reporting that the machine still needs
# a person - a failed update, a paused package queue, a package that had to be
# skipped - waits until it is dismissed, since a message like that is only ever
# useful if it is still there when somebody comes back. Everything else, an
# update that simply worked included, times out on its own.
# Say that an update has started. Worth keeping on: while one runs, a shutdown
# request is refused and the desktop answers with an untranslated polkit
# password prompt that never mentions updates. This message is replaced by the
# result when the run finishes, so it costs one bubble, not two.
# password prompt that never mentions updates. The message is withdrawn again
# when the result arrives, so it costs one bubble, not two.
#
# Separate from the progress bar in the notification area, which is not
# optional and appears whenever the desktop supports one.
NotifyOnStart=yes
# Say something after a successful update. Nothing is ever shown when there
@@ -108,6 +118,9 @@ NotifyOnSuccess=yes
# Report failures.
NotifyOnError=yes
# Point out that a kernel update needs a restart. The machine is never
# restarted automatically.
NotifyReboot=yes
# A kernel update that needs a restart is not announced by a notification. The
# running kernel loses its module tree as soon as pacman unpacks the new one,
# so that would fire while the run is still working through AUR packages and
# Flatpaks, and reads as an invitation to restart in the middle of it.
# `cachy-auto-update status` reports it instead. The machine is never restarted
# automatically either way.
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
#
# cachy-auto-update-progress - the update's progress bar on the desktop
#
# Started by the update runner, once per logged-in user, inside that user's
# session. Reads one instruction per line on stdin and turns it into the same
# progress entry the desktop shows while Dolphin copies files:
#
# info<TAB>text headline, already translated by the caller
# detail<TAB>name<TAB>value a labelled line under "Details"
# total<TAB>n how many items this step has
# done<TAB>n how many of them are finished
# percent<TAB>n overall progress, 0-100
# end[<TAB>message] finish; a message marks the job as failed
#
# Why a separate process at all: the desktop ties the progress entry to the
# D-Bus connection that asked for it and withdraws the entry the moment that
# connection goes away. One-shot callers - gdbus, busctl, dbus-send - therefore
# cannot drive one, because each invocation is its own connection that closes
# again immediately. So something has to sit there and hold the connection open
# for as long as the update takes, and read its orders from somewhere else.
#
# Copyright (C) 2026 Felitendo
# SPDX-License-Identifier: GPL-3.0-or-later
import sys
try:
from gi.repository import Gio, GLib
except ImportError:
# No GLib bindings: the update itself is unaffected, there is just no bar.
# Stdin is still drained, because the runner writes into a pipe and a
# reader that walks away would eventually block the update behind a full
# pipe buffer.
for _ in sys.stdin:
pass
sys.exit(0)
JOB_SERVICE = "org.kde.JobViewServer"
JOB_PATH = "/JobViewServer"
DESKTOP_ENTRY = "cachy-auto-update"
class Job:
"""The progress entry, or a do-nothing stand-in where there is no desk."""
def __init__(self):
self.bus = None
self.path = None
def open(self):
self.bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
reply = self.bus.call_sync(
JOB_SERVICE, JOB_PATH, "org.kde.JobViewServerV2", "requestView",
# capabilities 0: no cancel and no pause button. Neither can be
# honoured - pacman's commit phase is not interruptible - and a
# button that does nothing is worse than no button.
GLib.Variant("(sia{sv})", (DESKTOP_ENTRY, 0, {})),
GLib.VariantType("(o)"), Gio.DBusCallFlags.NONE, -1, None)
self.path = reply.unpack()[0]
def call(self, method, variant):
if self.path is None:
return
try:
self.bus.call_sync(
JOB_SERVICE, self.path, "org.kde.JobViewV2", method, variant,
None, Gio.DBusCallFlags.NONE, -1, None)
except GLib.Error:
# The desktop went away mid-update - a logout, or a plasmashell
# restart. The update carries on without a bar.
self.path = None
def close(self, message=""):
# Always terminate explicitly. A job whose owner simply disappears is
# reported by the desktop as "the application closed unexpectedly",
# which would turn every successful update into a failure notice.
#
# The error code is what decides how the entry is labelled; passing a
# message to terminate() on its own still files the job as completed,
# which next to "the update was stopped before it finished" reads as a
# contradiction.
#
# 100 is KJob::UserDefinedError, and the value does matter: 1 is
# KJob::KilledJobError, which the desktop discards without showing
# anything on the grounds that whoever killed the job already knows.
if message:
self.call("setError", GLib.Variant("(u)", (100,)))
self.call("terminate", GLib.Variant("(s)", (message,)))
self.path = None
def main():
job = Job()
try:
job.open()
except GLib.Error:
# No job server on this desktop - anything that is not Plasma. Same
# deal as a missing binding: drain stdin, stay out of the way.
for _ in sys.stdin:
pass
return 0
unit = "items"
try:
for line in sys.stdin:
fields = line.rstrip("\n").split("\t")
cmd = fields[0]
arg = fields[1] if len(fields) > 1 else ""
if cmd == "info":
job.call("setInfoMessage", GLib.Variant("(s)", (arg,)))
elif cmd == "detail" and len(fields) > 2:
job.call("setDescriptionField",
GLib.Variant("(uss)", (0, arg, fields[2])))
elif cmd == "total":
job.call("setTotalAmount", GLib.Variant("(ts)", (int(arg), unit)))
elif cmd == "done":
job.call("setProcessedAmount", GLib.Variant("(ts)", (int(arg), unit)))
elif cmd == "percent":
job.call("setPercent", GLib.Variant("(u)", (int(arg),)))
elif cmd == "end":
job.close(arg)
break
except (ValueError, IndexError):
# A malformed line is a bug on the writing side, not a reason to leave
# a stuck progress bar on somebody's desktop.
pass
except KeyboardInterrupt:
pass
finally:
job.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+36 -9
View File
@@ -14,7 +14,7 @@ set -uo pipefail
CAU_LIBDIR="${CAU_LIBDIR:-@LIBDIR@}"
for _mod in common config users conditions locks notify \
for _mod in common config users conditions locks notify progress \
pkg_pacman pkg_aur pkg_flatpak pkg_appimage; do
# shellcheck source=/dev/null
if ! source "$CAU_LIBDIR/$_mod.sh"; then
@@ -71,6 +71,10 @@ cau_log_open
# all landed or none did; only our own bookkeeping is at risk here.
_cau_interrupted() {
cau_error "Update run interrupted ($1)"
# Before anything else: a progress entry whose owner merely disappears is
# reported by the desktop as an application crash, so a killed run would
# leave a failure message behind on top of everything else.
cau_progress_end failed "The update was stopped before it finished."
cau_state_write last_result interrupted
exit 130
}
@@ -121,7 +125,7 @@ fi
# before the busy check, because otherwise every future run would defer on it
# forever and the machine would quietly stop updating.
if cau_recover_stale_lock; then
cau_notify normal \
cau_notify normal no \
"Finishing an interrupted update" \
"The last update was cut short, most likely because the machine was switched off. It is being finished now."
fi
@@ -132,7 +136,7 @@ CAU_SKIP_REASON=''
if cau_package_manager_busy; then
if cau_track_stale_lock; then
cau_warn "pacman's database lock appears to be stale"
cau_notify normal \
cau_notify normal yes \
"Package database locked" \
"pacman's lock file looks left over from an interrupted update. Updates are paused until it is cleared."
fi
@@ -178,6 +182,16 @@ fi
cau_info "Starting update run"
failed=0
# Open the desktop's progress bar, told up front which steps this run will
# perform. Only those count towards the bar, so a machine with no Flatpaks
# does not sit at 85% for the last second of the run.
progress_steps=(repo)
[[ $CFG_AUR == yes ]] && progress_steps+=(aur)
[[ $CFG_FLATPAK == yes ]] && progress_steps+=(flatpak)
[[ $CFG_APPIMAGE == yes ]] && progress_steps+=(appimage)
progress_steps+=(cleanup)
cau_progress_begin "${progress_steps[@]}"
if ! cau_pacman_update; then
failed=1
fi
@@ -196,6 +210,15 @@ fi
cau_pacman_cleanup
# The work is over; the bar goes away and the result takes over from here. No
# label on a failure: the notification below carries that, and it stays up
# until it is dismissed.
if (( failed )); then
cau_progress_end failed
else
cau_progress_end ok
fi
pacnew="$(cau_pacman_pacnew_count)"
if [[ $pacnew =~ ^[0-9]+$ ]] && (( pacnew > 0 )); then
cau_info "$pacnew .pacnew file(s) present; left untouched"
@@ -213,7 +236,7 @@ cau_state_write last_counts \
if (( failed )); then
cau_state_write last_result failed
cau_error "Update run finished with errors"
[[ $CFG_NOTIFY_ERROR == yes ]] && cau_notify_tagged run yes critical \
[[ $CFG_NOTIFY_ERROR == yes ]] && cau_notify_tagged run yes critical yes \
"Update failed" \
"Something went wrong while updating. Run 'cachy-auto-update log' for details."
else
@@ -222,7 +245,8 @@ else
cau_info "Update run finished successfully ($total item(s) updated)"
if (( total > 0 )) && [[ $CFG_NOTIFY_SUCCESS == yes ]]; then
cau_notify_tagged run yes low "System updated" "%d updates were installed." "$total"
cau_notify_tagged run yes low no \
"System updated" "%d updates were installed." "$total"
fi
fi
@@ -234,7 +258,7 @@ if [[ -n $CAU_PACMAN_HELD ]]; then
cau_state_write held_back "$CAU_PACMAN_HELD"
cau_warn "Held back: $CAU_PACMAN_HELD"
if [[ $CAU_PACMAN_HELD != "$prev_held" && $CFG_NOTIFY_ERROR == yes ]]; then
cau_notify normal "Some packages were held back" \
cau_notify normal yes "Some packages were held back" \
"%s could not be updated and was skipped. Everything else is up to date." \
"$CAU_PACMAN_HELD"
fi
@@ -242,12 +266,15 @@ else
cau_state_clear held_back
fi
# Recorded, deliberately not announced. The running kernel loses its module
# tree the moment pacman unpacks the new one, so this turns true partway
# through a run that still has AUR builds and Flatpaks ahead of it - and a
# "restart recommended" bubble arriving then reads as an invitation to restart
# while the update is still going. `cachy-auto-update status` and the menu say
# so instead, where nobody is being interrupted mid-transaction.
if cau_pacman_reboot_needed; then
cau_state_write reboot_needed 1
cau_info "A kernel update needs a restart"
[[ $CFG_NOTIFY_REBOOT == yes ]] && cau_notify normal \
"Restart recommended" \
"A new kernel was installed. Please restart when it suits you."
else
cau_state_write reboot_needed 0
fi
-1
View File
@@ -130,7 +130,6 @@ cau_config_load() {
CFG_NOTIFY_START=no; cau_config_bool NotifyOnStart yes && CFG_NOTIFY_START=yes
CFG_NOTIFY_SUCCESS=no; cau_config_bool NotifyOnSuccess yes && CFG_NOTIFY_SUCCESS=yes
CFG_NOTIFY_ERROR=no; cau_config_bool NotifyOnError yes && CFG_NOTIFY_ERROR=yes
CFG_NOTIFY_REBOOT=no; cau_config_bool NotifyReboot yes && CFG_NOTIFY_REBOOT=yes
CFG_INTERVAL="$(cau_config_get UpdateInterval 1d)"
CFG_INTERVAL_SECONDS="$(cau_duration_to_seconds "$CFG_INTERVAL" 86400)"
-1
View File
@@ -131,7 +131,6 @@ CAU_SETTINGS=(
"NotifyOnStart|bool|yes|Notify when an update starts"
"NotifyOnSuccess|bool|yes|Notify after a successful update"
"NotifyOnError|bool|yes|Notify when something goes wrong"
"NotifyReboot|bool|yes|Notify when a restart is needed"
"UpdateInterval|choice:6h 12h 1d 2d 1w|1d|Time between update runs"
"SkipWhenGaming|bool|yes|Postpone while a game is running"
"RequireAC|bool|no|Only update on mains power"
+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"
+2
View File
@@ -43,6 +43,8 @@ cau_appimage_update() {
local user uid count rc=0
local -a cmd
cau_progress_step appimage "AppImages"
while read -r user uid; do
[[ -n $user ]] || continue
+7
View File
@@ -126,6 +126,8 @@ cau_aur_update() {
cau_aur_ready || return 0
cau_progress_step aur "AUR packages"
pending="$(cau_aur_pending)"
if (( pending == 0 )); then
cau_info "No AUR updates pending"
@@ -134,10 +136,15 @@ cau_aur_update() {
fi
cau_info "Updating $pending AUR package(s) with $CAU_AUR_HELPER"
# The helper builds each package from source with no counter this side of
# its output, so the bar sits at the start of the step until it is done.
cau_progress_item 0 "$pending"
mapfile -t args < <(cau_aur_helper_args)
if cau_run_logged cau_as_build_user "$CAU_AUR_HELPER" "${args[@]}"; then
CAU_AUR_COUNT="$pending"
cau_progress_item "$pending"
cau_state_clear aur_failures
return 0
fi
+4
View File
@@ -28,14 +28,18 @@ cau_flatpak_update() {
cau_have flatpak || return 0
cau_progress_step flatpak "Flatpaks"
# refresh appstream metadata first so remote-ls sees current versions
cau_run_logged flatpak update --appstream --system --noninteractive || true
pending="$(cau_flatpak_pending_system)"
if (( pending > 0 )); then
cau_info "Updating $pending system Flatpak(s)"
cau_progress_item 0 "$pending"
if cau_run_logged flatpak update --system --noninteractive --assumeyes; then
CAU_FLATPAK_COUNT=$(( CAU_FLATPAK_COUNT + pending ))
cau_progress_item "$pending"
else
cau_warn "System Flatpak update failed"
rc=1
+57 -3
View File
@@ -27,6 +27,40 @@ cau_pacman_flags() {
done
}
# _cau_pacman_progress_watch <logfile>
# Feeds the desktop's progress bar from pacman's own transaction counter.
# --noprogressbar makes pacman print one "(120/260) upgrading foo" line per
# package, and that is the only live measure of how far a transaction has got:
# checkupdates knows the total beforehand, but nothing else knows the position.
#
# The other (n/m) sequences pacman prints - checking keys in keyring, checking
# package integrity, loading package files - are deliberately not matched. Each
# counts up to the same total, so following them would run the bar to the end
# three times over before the first package was unpacked.
#
# Read from the log by polling rather than from a pipe: the log is written
# either by pacman directly or through tee, depending on whether a person is
# watching, and one reader that works for both is worth more here than the
# second or so of latency it costs.
_cau_pacman_progress_watch() {
local log="$1" line last='' pkg
while :; do
sleep 1
line="$(grep -aoE '^\([[:space:]]*[0-9]+/[0-9]+\) (upgrading|installing|reinstalling|downgrading|removing) [^[:space:]]+' \
"$log" 2>/dev/null | tail -n1)"
[[ -n $line && $line != "$last" ]] || continue
last="$line"
[[ $line =~ ^\([[:space:]]*([0-9]+)/([0-9]+)\)[[:space:]]+[a-z]+[[:space:]]+(.+)$ ]] || continue
cau_progress_item "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}"
pkg="${BASH_REMATCH[3]%...}"
cau_progress_detail "Package" "$pkg"
done
}
# _cau_pacman_exec <logfile> <pacman args...>
# Captures pacman's output for classification, and streams it as well when a
# person is watching. Upgrading a few hundred packages takes minutes; without
@@ -35,13 +69,28 @@ cau_pacman_flags() {
_cau_pacman_exec() {
local log="$1"
shift
local rc watcher=0
# Only worth a second process and a grep per second if a bar exists to feed.
if cau_progress_active; then
_cau_pacman_progress_watch "$log" &
watcher=$!
fi
if [[ -n $CAU_INTERACTIVE ]]; then
pacman "$@" 2>&1 | tee "$log"
return "${PIPESTATUS[0]}"
rc="${PIPESTATUS[0]}"
else
pacman "$@" > "$log" 2>&1
rc=$?
fi
pacman "$@" > "$log" 2>&1
if (( watcher )); then
kill "$watcher" 2>/dev/null
wait "$watcher" 2>/dev/null
fi
return "$rc"
}
# cau_pacman_pending
@@ -117,6 +166,8 @@ cau_pacman_update() {
local log kind
local -a flags
cau_progress_step repo "Repository packages"
if ! cau_pacman_pending; then
cau_info "No repository updates pending"
return 0
@@ -126,6 +177,7 @@ cau_pacman_update() {
CAU_PACMAN_COUNT="$(grep -c . <<< "$CAU_PACMAN_PENDING")"
[[ $CAU_PACMAN_COUNT =~ ^[0-9]+$ ]] || CAU_PACMAN_COUNT=0
cau_info "Updating $CAU_PACMAN_COUNT repository package(s)"
cau_progress_item 0 "$CAU_PACMAN_COUNT"
else
# checkupdates is unavailable, so the list is unknown and pacman is
# asked to work it out itself.
@@ -141,7 +193,7 @@ cau_pacman_update() {
# updates and, on a German system, is not even translated. Somebody who was
# simply told beforehand does not end up staring at that.
if [[ $CFG_NOTIFY_START == yes ]]; then
cau_notify_tagged run no normal \
cau_notify_tagged run no normal no \
"Installing updates" \
"%d packages are being updated. Please leave the computer switched on until this is done." \
"${CAU_PACMAN_COUNT:-0}"
@@ -258,6 +310,8 @@ cau_pacman_pacnew_count() {
cau_pacman_cleanup() {
local -a orphans
cau_progress_step cleanup "Cleaning up"
if [[ $CFG_REMOVE_ORPHANS == yes ]]; then
mapfile -t orphans < <(pacman -Qtdq 2>/dev/null)
if (( ${#orphans[@]} )); then
+267
View File
@@ -0,0 +1,267 @@
# 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
}