diff --git a/Makefile b/Makefile index 43da8c4..8562aca 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/README.md b/README.md index ca8ae7b..bd0c502 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/doc/cachy-auto-update.1.scd b/doc/cachy-auto-update.1.scd index 79f9502..c38e5d5 100644 --- a/doc/cachy-auto-update.1.scd +++ b/doc/cachy-auto-update.1.scd @@ -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 diff --git a/po/cachy-auto-update.pot b/po/cachy-auto-update.pot index 814a81d..d3b7061 100644 --- a/po/cachy-auto-update.pot +++ b/po/cachy-auto-update.pot @@ -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 "" diff --git a/po/de.po b/po/de.po index e73d0ef..5633b44 100644 --- a/po/de.po +++ b/po/de.po @@ -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" diff --git a/res/applications/cachy-auto-update.desktop b/res/applications/cachy-auto-update.desktop new file mode 100644 index 0000000..39b798c --- /dev/null +++ b/res/applications/cachy-auto-update.desktop @@ -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 diff --git a/res/config/cachy-auto-update.conf b/res/config/cachy-auto-update.conf index 339a661..bd1977e 100644 --- a/res/config/cachy-auto-update.conf +++ b/res/config/cachy-auto-update.conf @@ -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. diff --git a/src/cachy-auto-update-progress b/src/cachy-auto-update-progress new file mode 100644 index 0000000..0597c45 --- /dev/null +++ b/src/cachy-auto-update-progress @@ -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: +# +# infotext headline, already translated by the caller +# detailnamevalue a labelled line under "Details" +# totaln how many items this step has +# donen how many of them are finished +# percentn overall progress, 0-100 +# end[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()) diff --git a/src/cachy-auto-update-run b/src/cachy-auto-update-run index dca29c7..9ab9cbf 100644 --- a/src/cachy-auto-update-run +++ b/src/cachy-auto-update-run @@ -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 diff --git a/src/lib/config.sh b/src/lib/config.sh index 1c8b987..eb59da8 100644 --- a/src/lib/config.sh +++ b/src/lib/config.sh @@ -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)" diff --git a/src/lib/menu.sh b/src/lib/menu.sh index ea50869..7c12bef 100644 --- a/src/lib/menu.sh +++ b/src/lib/menu.sh @@ -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" diff --git a/src/lib/notify.sh b/src/lib/notify.sh index 23c4cc0..69cacf4 100644 --- a/src/lib/notify.sh +++ b/src/lib/notify.sh @@ -15,30 +15,68 @@ CAU_NOTIFY_ICON="system-software-update" CAU_NOTIFY_QUEUE_MAX=20 -# cau_notify [body printf args...] +# cau_notify [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 <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" diff --git a/src/lib/pkg_appimage.sh b/src/lib/pkg_appimage.sh index 424a5da..0ac4d9c 100644 --- a/src/lib/pkg_appimage.sh +++ b/src/lib/pkg_appimage.sh @@ -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 diff --git a/src/lib/pkg_aur.sh b/src/lib/pkg_aur.sh index 3e4a09f..53dc2a0 100644 --- a/src/lib/pkg_aur.sh +++ b/src/lib/pkg_aur.sh @@ -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 diff --git a/src/lib/pkg_flatpak.sh b/src/lib/pkg_flatpak.sh index 3451822..fccd457 100644 --- a/src/lib/pkg_flatpak.sh +++ b/src/lib/pkg_flatpak.sh @@ -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 diff --git a/src/lib/pkg_pacman.sh b/src/lib/pkg_pacman.sh index 148b16e..eec8b8b 100644 --- a/src/lib/pkg_pacman.sh +++ b/src/lib/pkg_pacman.sh @@ -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 diff --git a/src/lib/progress.sh b/src/lib/progress.sh new file mode 100644 index 0000000..288c328 --- /dev/null +++ b/src/lib/progress.sh @@ -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 +}