Compare commits

...
10 Commits
Author SHA1 Message Date
Felitendo 162b30ba38 cachy-auto-update 1.3.0
The progress bar stops freezing on a count it made up, drops the steps that
have no work, and carries the run log's last lines under Details.
2026-08-20 19:57:40 +02:00
Felitendo c8666da714 Show the run log's last lines under Details, live
The percentage says an update is alive. It does not say what it is alive
doing, and for the longest stretch of a run there was no way to find out:
an AUR package compiling for a quarter of an hour talks constantly, and
every word of it went into a file nobody was looking at.

So the run log's last five lines now travel with the progress entry and
sit under Details, refreshed every two seconds for as long as the update
lasts. Polled rather than followed, for the same reason the pacman
watcher polls: only the last few lines are ever displayed, so every line
in between would be work nobody sees, and re-reading the tail whole makes
truncation and rotation of the log a non-event.

The job model behind this interface carries exactly two description
fields - descriptionValue1 and 2, there is no third - so one stays with
the package being worked on and the other takes the tail. Newlines inside
a field do render, which is what lets five lines share one of them. They
travel tab separated because the protocol is one instruction per line,
and a tab inside a log line becomes a space first: either renders as
whitespace, but a line split in half renders as nonsense. Five lines
clipped to 120 columns also keeps an instruction well inside PIPE_BUF,
which is what makes the write atomic against the runner sending its own
progress down the same pipe.
2026-08-20 19:55:43 +02:00
Felitendo f9cd8a0ace Keep the progress bar moving for the whole run
Three stretches of a run had no way to report anything, and the bar
handled each of them badly.

pacman prints nothing at all between "starting full system upgrade" and
the transaction it eventually prepares. On a 161-package backlog that
silence ran to three minutes and nineteen seconds, and the bar spent all
of it frozen on "0 of 161" - a counter seeded from checkupdates before
there was anything to count. Working out the upgrade is now a step of its
own, with a label and deliberately no item count, because the number was
the part that was lying.

A step that turns out to have no work is dropped from the bar instead of
being handed its share for nothing. Packages already in the cache are
never announced by pacman, so a run that only has to unpack used to jump
thirty points the moment unpacking started; the same went for AUR with
nothing pending and for machines with no Gear Lever or no Flatpaks.

pacman's output is line-buffered through stdbuf. Writing to a log rather
than a terminal, libc released it in 4KB blocks - around a hundred and
sixty "upgrading foo..." lines at a time - so the bar sat still and then
leapt to the end of the step in one poll.

What is left is work whose length genuinely cannot be known: resolving a
transaction, and an AUR helper compiling for a quarter of an hour. Those
now creep along a curve that approaches the end of their step without
reaching it. The item counter stays put throughout - it is the field that
would be lying if it moved - and any real report overtakes the creep. A
bar that has not moved since it appeared is read as a hang, and somebody
who reads it that way reaches for the power button mid-update.

The bar is also monotonic now. Dropping a step rescales the run, and the
conflict-recovery loop restarts pacman and its tally from the top; both
are honest, neither is a reason to show a bar that retreats.
2026-08-20 19:45:56 +02:00
Felitendo 8834abe648 cachy-auto-update 1.2.2
The progress bar counts the download as well as the transaction, and every
step says outright that an update is running.
2026-08-14 14:25:11 +02:00
Felitendo b796b2711a Move the bar while packages are downloading
The progress entry sat at "0 von 123" for the whole download and only
started counting once pacman began unpacking - which on a domestic line is
most of the run spent looking like nothing was happening. What was being
watched for was the transaction, and the transaction had not started yet.

Downloading is now a step of its own, worth 30 of the bar against the
transaction's 40, counted the same way: one line per package out of
pacman's "foo-1.2-1-x86_64 downloading...". The database sync just before
it prints the identical shape with the suffix that would give it away
already stripped, so the tally starts only after the ":: Retrieving
packages..." header. Packages already in the cache never announce
themselves, so the step regularly ends short of its total and hands the
rest of its share over when unpacking begins.

The headline follows: "Updates werden heruntergeladen", then
"Systempakete werden aktualisiert".
2026-08-14 14:22:05 +02:00
Felitendo 6538c1510d Say on the progress bar that this is an update running
The headline read "Paketquellen" over KDE's own "0 von 123 Elementen" -
two nouns and a count, with nothing anywhere saying that the machine was
updating itself. Nobody goes looking for this entry; it turns up beside
whatever they were doing, so it has to explain itself in one line.

Each step now says so outright: "Systempakete werden aktualisiert",
"Flatpak-Programme werden aktualisiert", and so on.

The count line underneath belongs to Plasma, which only counts in bytes,
files, dirs or items - "Paketen" is not on offer, so "Elementen" stays,
now under a headline that says what is being counted.
2026-08-14 14:13:54 +02:00
Felitendo ea3f0d21fa Make the progress bar follow the transaction it was supposed to be watching
It sat at "0 of 218 items" for an entire run. The watcher looked for
"(120/218) upgrading foo", which pacman only prints when it is drawing its
progress bar - and the unattended runs this exists for are precisely the ones
started with --noprogressbar, where it instead prints "upgrading glibc..." with
no counter at all. So nothing ever matched and the position never moved.

Both shapes are handled now. Where there is no counter the packages are tallied
here, one line each, and the total is read from the "Package (218)" header
pacman prints before it starts - a better number than checkupdates gives, since
that counts packages with an update available and knows nothing about new
dependencies pulled in alongside them. Replayed against a real 218-package run
from the log: 63, 137, 215, 218 of 218, with the package name in each step.

Offer to switch off CachyOS's reboot notification while enabling. cachyos-hooks
ships a PostTransaction hook that pops up "Reboot recommended!" the moment a
kernel, driver or systemd package is unpacked. That is fine for a manual
upgrade, where the transaction is the last thing happening. In an unattended
one it lands mid-run, with AUR packages still to build and Flatpaks still to
pull, and asking for a restart there is an invitation to cut the update in
half. Overriding the hook by name from /etc/pacman.d/hooks is the standard way
to switch a distribution hook off and is undone by deleting the link.

Offered rather than done, like the existing cachy-update prompt: it is another
package's behaviour and it applies to manual upgrades too.
2026-08-14 11:34:03 +02:00
Felitendo 6aa9b147b8 Put a progress bar on the desktop, and repair the notifications around it
An unattended run takes twenty minutes and said nothing at all while it worked.
The notification area now carries a live entry for the duration - which step is
running, which package is being unpacked, item count, percentage. It is a job
in the sense of org.kde.JobViewServer, the same mechanism Dolphin uses while
copying files, rather than a notification, which is what makes it a real bar
instead of a line of text.

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

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

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

"Restart recommended" is gone, and NotifyReboot with it. The running kernel
loses its module tree the moment pacman unpacks the new one, so the notice
fired while the run was still building AUR packages and pulling Flatpaks, where
it reads as an invitation to restart in the middle of an update. The state is
still recorded and cachy-auto-update status still reports it. The option went
rather than only its default, because an installed system keeps its own
configuration file and would have carried on notifying regardless.
2026-08-14 10:49:02 +02:00
Felitendo a17aed4b3d Lower the battery threshold to 30% and default the cachy-update prompt to yes
The prompt offering to silence cachy-update's own update notification now
defaults to yes: once updates install themselves that notification is nothing
but noise, so the common answer should be the one you get by pressing Enter.

It also accepts "j" now. The prompt is translated, so a German user reads
"[J/n]" and types the German letter - which the old check for "y" alone
silently read as a refusal.
2026-08-08 16:30:20 +02:00
Felitendo 2fa830284e Make the settings screen redraw 50x faster
Arrow-key navigation took 435 ms per keypress, which reads as the whole console
reloading on every press - because it effectively was. The cost was forks: each
frame ran a command substitution per label for the value, the translation and
the rendered state, 54 subshells for eighteen rows.

The frame is now assembled in memory and written once. Translations, the split
specs and the terminfo clear string are resolved before the loop; values are
re-read only after something actually changes, not on cursor movement. Helpers
on that path assign to a variable instead of printing, since printing is what
forced the substitution.

Measured on the same machine: 435 ms -> 7.5 ms per keypress.
2026-08-08 16:25:09 +02:00
19 changed files with 1502 additions and 124 deletions

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 # Overridable so a packager can pass the version it is actually building
# (`make VERSION=$pkgver`). The literal below is the fallback for builds # (`make VERSION=$pkgver`). The literal below is the fallback for builds
# straight from a checkout, and is what a release tag has to carry. # straight from a checkout, and is what a release tag has to carry.
VERSION ?= 1.1.0 VERSION ?= 1.3.0
PREFIX ?= /usr PREFIX ?= /usr
DESTDIR ?= DESTDIR ?=
@@ -64,6 +64,13 @@ check:
else \ else \
echo "shellcheck not found - skipped"; \ echo "shellcheck not found - skipped"; \
fi 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 \ @if command -v visudo >/dev/null 2>&1; then \
visudo -cf res/sudoers/cachy-auto-update >/dev/null && echo "ok sudoers"; \ visudo -cf res/sudoers/cachy-auto-update >/dev/null && echo "ok sudoers"; \
fi fi
@@ -72,6 +79,9 @@ install: build
# executables # executables
install -Dm755 src/cachy-auto-update "$(DESTDIR)$(BINDIR)/cachy-auto-update" install -Dm755 src/cachy-auto-update "$(DESTDIR)$(BINDIR)/cachy-auto-update"
install -Dm755 src/cachy-auto-update-run "$(DESTDIR)$(LIBEXECDIR)/cachy-auto-update-run" 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 # the version and the resolved lib path are baked in at install time
sed -i -e 's|@VERSION@|$(VERSION)|g' \ sed -i -e 's|@VERSION@|$(VERSION)|g' \
-e 's|@LIBDIR@|$(LIBDIR)|g' \ -e 's|@LIBDIR@|$(LIBDIR)|g' \
@@ -114,6 +124,10 @@ install: build
install -Dm644 res/autostart/cachy-auto-update-notify.desktop \ install -Dm644 res/autostart/cachy-auto-update-notify.desktop \
"$(DESTDIR)$(XDGAUTOSTART)/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 # translations
@for l in $(LINGUAS); do \ @for l in $(LINGUAS); do \
if [ -f "po/$$l.mo" ]; then \ 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.service"
rm -f "$(DESTDIR)$(SYSTEMDDIR)/cachy-auto-update.timer" rm -f "$(DESTDIR)$(SYSTEMDDIR)/cachy-auto-update.timer"
rm -f "$(DESTDIR)$(XDGAUTOSTART)/cachy-auto-update-notify.desktop" 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" rm -f "$(DESTDIR)$(MANDIR)/man1/cachy-auto-update.1"
clean: clean:
+96 -8
View File
@@ -74,7 +74,7 @@ deliberately.
A run is postponed — and retried an hour later — when: A run is postponed — and retried an hour later — when:
- the battery is below 40 % (ignored on mains power; desktops without a battery - the battery is below 30 % (ignored on mains power; desktops without a battery
are never affected), are never affected),
- a game is running: GameMode, a known game process, or anything holding a - a game is running: GameMode, a known game process, or anything holding a
blocking idle inhibitor, blocking idle inhibitor,
@@ -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 `cachy-auto-update status` prints every one of these individually, which is the
fastest way to find out why nothing is happening. fastest way to find out why nothing is happening.
The machine is **never** restarted on its own. When a kernel update needs a The machine is **never** restarted on its own. A kernel update that needs a
restart, you get a notification saying so. 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 ## 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 **A notification goes out before the transaction starts** — "Installing
updates, please leave the computer switched on until this is done" — and is 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 withdrawn again when the result arrives, so it costs one bubble rather than
rather than two. This exists because of what the next paragraph does *not* do. 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 **Suspend and a normal shutdown are blocked.** The run holds a
`systemd-inhibit --what=sleep:shutdown --mode=block` lock, so closing the lid or `systemd-inhibit --what=sleep:shutdown --mode=block` lock, so closing the lid or
@@ -170,6 +180,79 @@ 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 pacman transaction is bracketed by a pre and post snapshot, so a genuinely
broken upgrade can still be rolled back with `snapper rollback`. 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.
- Working out the upgrade, downloading it and unpacking it are three separate
steps. The first is the one that used to look broken: between
`:: Starting full system upgrade...` and the transaction it eventually
prepares, pacman prints nothing at all, and on a large backlog that silence
runs to minutes. A counter frozen at "0 of 161" reads as a stuck update, so
that stretch carries a label and deliberately no counter. On a domestic line
the download is then the longest of the three, and calling the whole thing
"installing" would leave the bar at 4% for six minutes.
- A step that turns out to have no work is dropped from the bar rather than
handed its share for nothing. Packages already in the cache are never
announced, so a run that only has to unpack skips the download step outright
instead of leaping 30% the moment unpacking starts — and likewise for AUR
with nothing pending, or a machine with no Flatpaks. The weights only ever
have to be right about the steps that actually run.
- **Expanding *Details* shows the run log's last five lines, live.** The
percentage says the update is alive; these say what it is alive doing. It
matters most where there is nothing to count — an AUR package compiling for
a quarter of an hour talks constantly, and all of it used to go into a file
nobody was looking at. The job model behind this interface carries exactly
two description fields (`descriptionValue1` and `2` — there is no third), so
one holds the current package and the other the tail; newlines inside a value
do render, which is what makes five lines fit in one field.
- pacman's output is line-buffered through `stdbuf`. Writing to a log rather
than a terminal, libc would hand it over in 4 KB blocks instead, and 4 KB of
`upgrading foo...` is on the order of a hundred and sixty packages arriving
at once — which is how a bar comes to sit still and then jump to the end.
- Neither counted phase gets a counter from pacman on an unattended run, so
both are counted a line at a time — `foo-1.2-1-x86_64 downloading...` and
`upgrading foo...`. The database sync just before prints the same shape
(` core downloading...`) with the suffix that would give it away already
stripped, so counting starts only after pacman's `:: Retrieving packages...`
header. 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 ## Configuration
`/etc/cachy-auto-update/cachy-auto-update.conf`, one `Key=Value` per line, every `/etc/cachy-auto-update/cachy-auto-update.conf`, one `Key=Value` per line, every
@@ -203,7 +286,7 @@ Two things are deliberately *not* automated:
- **File conflicts** (`exists in filesystem`) — forcing `--overwrite` could - **File conflicts** (`exists in filesystem`) — forcing `--overwrite` could
silently destroy something that was put there on purpose. 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 Signature failures trigger one keyring refresh and one retry, since a stale
keyring blocks everything else until it is fixed. keyring blocks everything else until it is fixed.
@@ -226,8 +309,13 @@ sudo systemd-sysusers && sudo systemd-tmpfiles --create
sudo cachy-auto-update enable sudo cachy-auto-update enable
``` ```
`make check` runs `bash -n` over everything, `shellcheck` when available, and `make check` runs `bash -n` over every shell file, `py_compile` over the
validates the sudoers drop-in with `visudo -c`. 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 ## Relationship to cachy-update
+73 -4
View File
@@ -100,14 +100,83 @@ inside each user's own account. AppImages are updated through Gear Lever, for
users with a graphical session, and AppImages whose application is currently users with a graphical session, and AppImages whose application is currently
running are skipped. running are skipped.
The machine is never restarted automatically. When a kernel update makes a The machine is never restarted automatically. A kernel update that makes a
restart necessary, a notification says so. 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.
Working out the upgrade, fetching it and unpacking it are three steps rather
than one. Between "Starting full system upgrade" and the transaction it
eventually prepares, pacman prints nothing at all, and on a large backlog that
silence runs to minutes; that stretch therefore carries a label of its own and
deliberately no item count, because a counter frozen at "0 of 161" reads as a
stuck update. On a domestic line the download is then the longest of the three,
and a bar that called the whole thing "installing" would sit near its beginning
for minutes at a time looking stuck.
Expanding "Details" shows the last five lines of the run log as they are
written, alongside the package currently being worked on. The percentage says
the update is alive; these lines say what it is alive doing, which matters most
during the stretches that have nothing countable to report - an AUR package
being compiled, above all. The job interface carries exactly two description
fields, so those are the two things shown.
A step that turns out to have no work is dropped from the bar instead of being
handed its share for nothing. Packages already in the cache are never
announced, so a run with everything already fetched skips the download step
outright rather than jumping when unpacking starts; the same applies to AUR
with nothing pending, or a machine with no Flatpaks installed.
Neither counted phase gets a counter from pacman on an unattended run, so both
are counted here, a line at a time: "foo-1.2-1-x86_64 downloading..." for the
first, "upgrading foo..." for the second. pacman's output is line-buffered
through *stdbuf*(1) so those lines arrive as they happen - writing to a log
rather than a terminal, libc would otherwise release them in 4KB blocks, around
a hundred and sixty packages at a time. pacman's 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 # INTERRUPTED UPDATES
Before a transaction starts, a notification says an update is running and asks 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 for the machine to be left on. It is withdrawn again when the result arrives,
run finishes. so one bubble is used rather than two.
While a transaction is running, *cachy-auto-update* holds a While a transaction is running, *cachy-auto-update* holds a
*systemd-inhibit*(1) lock on _sleep_ and _shutdown_ in blocking mode, so a *systemd-inhibit*(1) lock on _sleep_ and _shutdown_ in blocking mode, so a
+50 -10
View File
@@ -168,12 +168,23 @@ msgstr ""
msgid "base-devel is missing - AUR packages cannot be built without it." msgid "base-devel is missing - AUR packages cannot be built without it."
msgstr "" msgstr ""
msgid "cachy-update also notifies about available updates. Turn its notifications off? [y/N]" msgid "cachy-update also notifies about available updates. Turn its notifications off? [Y/n]"
msgstr "" msgstr ""
msgid "cachy-update's update check has been disabled." msgid "cachy-update's update check has been disabled."
msgstr "" msgstr ""
msgid "CachyOS shows a \"Reboot recommended\" notification while an update is still running. Turn it off? [Y/n]"
msgstr ""
#, c-format
msgid "The reboot notification has been turned off. Undo it by deleting %s."
msgstr ""
#, c-format
msgid "Could not write %s."
msgstr ""
msgid "No log yet." msgid "No log yet."
msgstr "" msgstr ""
@@ -218,6 +229,44 @@ msgstr ""
msgid "Without a command an interactive menu is shown." msgid "Without a command an interactive menu is shown."
msgstr "" msgstr ""
#. Progress bar
#. The headline of the desktop's progress entry. Whoever reads it has not gone
#. looking for it - it appears next to whatever they were doing - so each one
#. says outright that this is an update running, rather than naming the kind of
#. package on its own.
msgid "Checking for updates"
msgstr ""
msgid "Preparing the update"
msgstr ""
msgid "Downloading updates"
msgstr ""
msgid "Updating system packages"
msgstr ""
msgid "Updating AUR packages"
msgstr ""
msgid "Updating Flatpak apps"
msgstr ""
msgid "Updating AppImages"
msgstr ""
msgid "Cleaning up after the update"
msgstr ""
msgid "Log"
msgstr ""
msgid "Package"
msgstr ""
msgid "The update was stopped before it finished."
msgstr ""
#. Notifications #. Notifications
msgid "System updated" msgid "System updated"
msgstr "" msgstr ""
@@ -232,12 +281,6 @@ msgstr ""
msgid "Something went wrong while updating. Run 'cachy-auto-update log' for details." msgid "Something went wrong while updating. Run 'cachy-auto-update log' for details."
msgstr "" msgstr ""
msgid "Restart recommended"
msgstr ""
msgid "A new kernel was installed. Please restart when it suits you."
msgstr ""
msgid "Package database locked" msgid "Package database locked"
msgstr "" msgstr ""
@@ -291,9 +334,6 @@ msgstr ""
msgid "Notify when something goes wrong" msgid "Notify when something goes wrong"
msgstr "" msgstr ""
msgid "Notify when a restart is needed"
msgstr ""
msgid "Time between update runs" msgid "Time between update runs"
msgstr "" msgstr ""
+51 -11
View File
@@ -169,12 +169,23 @@ msgstr "Kein AUR-Helper gefunden – für AUR-Updates paru oder yay installieren
msgid "base-devel is missing - AUR packages cannot be built without it." msgid "base-devel is missing - AUR packages cannot be built without it."
msgstr "base-devel fehlt – ohne das lassen sich AUR-Pakete nicht bauen." msgstr "base-devel fehlt – ohne das lassen sich AUR-Pakete nicht bauen."
msgid "cachy-update also notifies about available updates. Turn its notifications off? [y/N]" msgid "cachy-update also notifies about available updates. Turn its notifications off? [Y/n]"
msgstr "cachy-update benachrichtigt ebenfalls über verfügbare Updates. Dessen Benachrichtigungen abschalten? [j/N]" msgstr "cachy-update benachrichtigt ebenfalls über verfügbare Updates. Dessen Benachrichtigungen abschalten? [J/n]"
msgid "cachy-update's update check has been disabled." msgid "cachy-update's update check has been disabled."
msgstr "Die Update-Prüfung von cachy-update wurde abgeschaltet." msgstr "Die Update-Prüfung von cachy-update wurde abgeschaltet."
msgid "CachyOS shows a \"Reboot recommended\" notification while an update is still running. Turn it off? [Y/n]"
msgstr "CachyOS zeigt eine Benachrichtigung \"Neustart empfohlen\", während ein Update noch läuft. Abschalten? [J/n]"
#, c-format
msgid "The reboot notification has been turned off. Undo it by deleting %s."
msgstr "Die Neustart-Benachrichtigung wurde abgeschaltet. Rückgängig durch Löschen von %s."
#, c-format
msgid "Could not write %s."
msgstr "%s konnte nicht geschrieben werden."
msgid "No log yet." msgid "No log yet."
msgstr "Noch kein Protokoll vorhanden." msgstr "Noch kein Protokoll vorhanden."
@@ -219,6 +230,44 @@ msgstr "Version anzeigen"
msgid "Without a command an interactive menu is shown." msgid "Without a command an interactive menu is shown."
msgstr "Ohne Befehl wird ein interaktives Menü angezeigt." msgstr "Ohne Befehl wird ein interaktives Menü angezeigt."
#. Progress bar
#. The headline of the desktop's progress entry. Whoever reads it has not gone
#. looking for it - it appears next to whatever they were doing - so each one
#. says outright that this is an update running, rather than naming the kind of
#. package on its own.
msgid "Checking for updates"
msgstr "Nach Updates wird gesucht"
msgid "Preparing the update"
msgstr "Update wird vorbereitet"
msgid "Downloading updates"
msgstr "Updates werden heruntergeladen"
msgid "Updating system packages"
msgstr "Systempakete werden aktualisiert"
msgid "Updating AUR packages"
msgstr "AUR-Pakete werden aktualisiert"
msgid "Updating Flatpak apps"
msgstr "Flatpak-Programme werden aktualisiert"
msgid "Updating AppImages"
msgstr "AppImages werden aktualisiert"
msgid "Cleaning up after the update"
msgstr "Aufräumen nach dem Update"
msgid "Log"
msgstr "Protokoll"
msgid "Package"
msgstr "Paket"
msgid "The update was stopped before it finished."
msgstr "Das Update wurde vor dem Ende abgebrochen."
#. Notifications #. Notifications
msgid "System updated" msgid "System updated"
msgstr "System aktualisiert" msgstr "System aktualisiert"
@@ -233,12 +282,6 @@ msgstr "Update fehlgeschlagen"
msgid "Something went wrong while updating. Run 'cachy-auto-update log' for details." 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“." 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" msgid "Package database locked"
msgstr "Paketdatenbank gesperrt" msgstr "Paketdatenbank gesperrt"
@@ -293,9 +336,6 @@ msgstr "Melden nach erfolgreichem Update"
msgid "Notify when something goes wrong" msgid "Notify when something goes wrong"
msgstr "Melden, wenn etwas schiefgeht" 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" msgid "Time between update runs"
msgstr "Abstand zwischen Update-Läufen" 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
+19 -6
View File
@@ -31,7 +31,7 @@ UpdateInterval=1d
# Never update while the battery is below this percentage. Ignored on mains # Never update while the battery is below this percentage. Ignored on mains
# power and on machines without a battery. # power and on machines without a battery.
MinBatteryPercent=40 MinBatteryPercent=30
# Update only while plugged in. Stricter than MinBatteryPercent. # Update only while plugged in. Stricter than MinBatteryPercent.
RequireAC=no RequireAC=no
@@ -94,11 +94,21 @@ RemoveOrphans=no
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Notification detail # 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 # 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 # request is refused and the desktop answers with an untranslated polkit
# password prompt that never mentions updates. This message is replaced by the # password prompt that never mentions updates. The message is withdrawn again
# result when the run finishes, so it costs one bubble, not two. # 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 NotifyOnStart=yes
# Say something after a successful update. Nothing is ever shown when there # Say something after a successful update. Nothing is ever shown when there
@@ -108,6 +118,9 @@ NotifyOnSuccess=yes
# Report failures. # Report failures.
NotifyOnError=yes NotifyOnError=yes
# Point out that a kernel update needs a restart. The machine is never # A kernel update that needs a restart is not announced by a notification. The
# restarted automatically. # running kernel loses its module tree as soon as pacman unpacks the new one,
NotifyReboot=yes # 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.
+52 -2
View File
@@ -84,6 +84,48 @@ cau_do_enable() {
fi fi
cau_offer_disable_cachy_update cau_offer_disable_cachy_update
cau_offer_disable_reboot_hook
}
# CachyOS ships a pacman hook that pops up "Reboot recommended!" the moment a
# kernel, driver or systemd package is unpacked. During a manual upgrade that
# is fine - the transaction is the last thing happening. During an unattended
# one it lands in the middle: the run still has AUR packages to build and
# Flatpaks to pull, and a notification asking for a restart right then is an
# invitation to cut the update in half.
#
# pacman lets a hook be overridden by name from /etc/pacman.d/hooks, which takes
# precedence over /usr/share/libalpm/hooks, and a symlink to /dev/null there
# disables it. That is the standard way to switch off a distribution hook, and
# it is undone by deleting the link.
#
# This one is offered, never decided: it is another package's behaviour and it
# applies to manual upgrades too.
CAU_REBOOT_HOOK="cachyos-reboot-required.hook"
cau_offer_disable_reboot_hook() {
local system="/usr/share/libalpm/hooks/$CAU_REBOOT_HOOK"
local override="/etc/pacman.d/hooks/$CAU_REBOOT_HOOK"
local answer
[[ -t 0 && -t 1 ]] || return 0
[[ -f $system ]] || return 0
[[ -e $override || -L $override ]] && return 0
printf '\n %s\n ' \
"$(cau_msg "CachyOS shows a \"Reboot recommended\" notification while an update is still running. Turn it off? [Y/n]")"
read -r answer || return 0
case "${answer,,}" in
''|y|yes|j|ja) ;;
*) return 0 ;;
esac
if mkdir -p /etc/pacman.d/hooks 2>/dev/null && ln -sfn /dev/null "$override" 2>/dev/null; then
cau_ok "$(cau_msg "The reboot notification has been turned off. Undo it by deleting %s." "$override")"
else
cau_bad "$(cau_msg "Could not write %s." "$override")"
fi
} }
# Whether an AUR helper exists at all, without the logging cau_aur_detect does. # Whether an AUR helper exists at all, without the logging cau_aur_detect does.
@@ -117,9 +159,17 @@ cau_offer_disable_cachy_update() {
(( found )) || return 0 (( found )) || return 0
printf '\n %s\n ' \ printf '\n %s\n ' \
"$(cau_msg "cachy-update also notifies about available updates. Turn its notifications off? [y/N]")" "$(cau_msg "cachy-update also notifies about available updates. Turn its notifications off? [Y/n]")"
read -r answer || return 0 read -r answer || return 0
[[ ${answer,,} == y ]] || return 0
# Defaults to yes: once updates install themselves, cachy-update's "N
# updates available" is purely noise. "j" is accepted too - the prompt is
# translated, so a German user types the German letter and used to have
# that silently read as "no".
case "${answer,,}" in
''|y|yes|j|ja) ;;
*) return 0 ;;
esac
while read -r user uid; do while read -r user uid; do
[[ -n $user ]] || continue [[ -n $user ]] || continue
+149
View File
@@ -0,0 +1,149 @@
#!/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"
# log<TAB>name<TAB>line<TAB>line... the run log's last lines, one per field
# 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 == "log" and len(fields) > 2:
# Field 1, and there is no field 2: the job model behind this
# interface carries exactly two, and anything further is
# discarded without complaint at the other end.
#
# The tail arrives one line per field, because the protocol
# itself is one instruction per line. Joined back up with the
# newlines the job view does render.
job.call("setDescriptionField",
GLib.Variant("(uss)", (1, arg, "\n".join(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())
+41 -9
View File
@@ -14,7 +14,7 @@ set -uo pipefail
CAU_LIBDIR="${CAU_LIBDIR:-@LIBDIR@}" 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 pkg_pacman pkg_aur pkg_flatpak pkg_appimage; do
# shellcheck source=/dev/null # shellcheck source=/dev/null
if ! source "$CAU_LIBDIR/$_mod.sh"; then 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. # all landed or none did; only our own bookkeeping is at risk here.
_cau_interrupted() { _cau_interrupted() {
cau_error "Update run interrupted ($1)" 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 cau_state_write last_result interrupted
exit 130 exit 130
} }
@@ -121,7 +125,7 @@ fi
# before the busy check, because otherwise every future run would defer on it # before the busy check, because otherwise every future run would defer on it
# forever and the machine would quietly stop updating. # forever and the machine would quietly stop updating.
if cau_recover_stale_lock; then if cau_recover_stale_lock; then
cau_notify normal \ cau_notify normal no \
"Finishing an interrupted update" \ "Finishing an interrupted update" \
"The last update was cut short, most likely because the machine was switched off. It is being finished now." "The last update was cut short, most likely because the machine was switched off. It is being finished now."
fi fi
@@ -132,7 +136,7 @@ CAU_SKIP_REASON=''
if cau_package_manager_busy; then if cau_package_manager_busy; then
if cau_track_stale_lock; then if cau_track_stale_lock; then
cau_warn "pacman's database lock appears to be stale" cau_warn "pacman's database lock appears to be stale"
cau_notify normal \ cau_notify normal yes \
"Package database locked" \ "Package database locked" \
"pacman's lock file looks left over from an interrupted update. Updates are paused until it is cleared." "pacman's lock file looks left over from an interrupted update. Updates are paused until it is cleared."
fi fi
@@ -178,6 +182,21 @@ fi
cau_info "Starting update run" cau_info "Starting update run"
failed=0 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=(resolve download 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[@]}"
# And carry the run log's last lines along with it, so "Details" shows what the
# update is doing during the stretches that have nothing to count - an AUR
# package building for a quarter of an hour, most of all.
cau_progress_tail_start "$CAU_RUNLOG"
if ! cau_pacman_update; then if ! cau_pacman_update; then
failed=1 failed=1
fi fi
@@ -196,6 +215,15 @@ fi
cau_pacman_cleanup 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)" pacnew="$(cau_pacman_pacnew_count)"
if [[ $pacnew =~ ^[0-9]+$ ]] && (( pacnew > 0 )); then if [[ $pacnew =~ ^[0-9]+$ ]] && (( pacnew > 0 )); then
cau_info "$pacnew .pacnew file(s) present; left untouched" cau_info "$pacnew .pacnew file(s) present; left untouched"
@@ -213,7 +241,7 @@ cau_state_write last_counts \
if (( failed )); then if (( failed )); then
cau_state_write last_result failed cau_state_write last_result failed
cau_error "Update run finished with errors" 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" \ "Update failed" \
"Something went wrong while updating. Run 'cachy-auto-update log' for details." "Something went wrong while updating. Run 'cachy-auto-update log' for details."
else else
@@ -222,7 +250,8 @@ else
cau_info "Update run finished successfully ($total item(s) updated)" cau_info "Update run finished successfully ($total item(s) updated)"
if (( total > 0 )) && [[ $CFG_NOTIFY_SUCCESS == yes ]]; then 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
fi fi
@@ -234,7 +263,7 @@ if [[ -n $CAU_PACMAN_HELD ]]; then
cau_state_write held_back "$CAU_PACMAN_HELD" cau_state_write held_back "$CAU_PACMAN_HELD"
cau_warn "Held back: $CAU_PACMAN_HELD" cau_warn "Held back: $CAU_PACMAN_HELD"
if [[ $CAU_PACMAN_HELD != "$prev_held" && $CFG_NOTIFY_ERROR == yes ]]; then 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." \ "%s could not be updated and was skipped. Everything else is up to date." \
"$CAU_PACMAN_HELD" "$CAU_PACMAN_HELD"
fi fi
@@ -242,12 +271,15 @@ else
cau_state_clear held_back cau_state_clear held_back
fi 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 if cau_pacman_reboot_needed; then
cau_state_write reboot_needed 1 cau_state_write reboot_needed 1
cau_info "A kernel update needs a restart" 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 else
cau_state_write reboot_needed 0 cau_state_write reboot_needed 0
fi fi
+21
View File
@@ -75,6 +75,27 @@ cau_msg() {
cau_msg_in "$(cau_ui_locale)" "$@" cau_msg_in "$(cau_ui_locale)" "$@"
} }
# cau_msg_into <locale> <msgid>
# Plain lookup with the result in CAU_MSG_RESULT and no printf formatting.
# For callers that redraw many labels per keypress, where wrapping cau_msg in a
# command substitution would cost a fork per label.
CAU_MSG_RESULT=''
cau_msg_into() {
local locale="$1" msgid="$2" cachekey
cachekey="${locale}"$'\x1f'"${msgid}"
if [[ -n ${CAU_MSG_CACHE[$cachekey]+set} ]]; then
CAU_MSG_RESULT="${CAU_MSG_CACHE[$cachekey]}"
return 0
fi
CAU_MSG_RESULT="$(LC_ALL="$locale" LANGUAGE="${locale%%.*}" gettext -- "$msgid" 2>/dev/null)"
[[ -n $CAU_MSG_RESULT ]] || CAU_MSG_RESULT="$msgid"
CAU_MSG_CACHE[$cachekey]="$CAU_MSG_RESULT"
return 0
}
# Translations are memoized. Every gettext lookup is a fork, and the settings # Translations are memoized. Every gettext lookup is a fork, and the settings
# screen redraws forty-odd labels per keypress; without this the redraw takes # screen redraws forty-odd labels per keypress; without this the redraw takes
# long enough that a keystroke arriving during it is lost when the terminal # long enough that a keystroke arriving during it is lost when the terminal
+18 -10
View File
@@ -20,11 +20,17 @@ _cau_config_slurp() {
return 0 return 0
} }
# cau_config_get <Key> [default] # _cau_config_lookup <Key> [default]
cau_config_get() { # Result in CAU_CONFIG_VALUE. Assigning rather than printing matters on the
# settings screen, which reads every key on every frame: a command substitution
# there is a fork, and forks were the entire cost of a redraw.
CAU_CONFIG_VALUE=''
_cau_config_lookup() {
local key="$1" default="${2:-}" val='' line local key="$1" default="${2:-}" val='' line
[[ -r $CAU_CONFIG ]] || { printf '%s\n' "$default"; return; } CAU_CONFIG_VALUE="$default"
[[ -r $CAU_CONFIG ]] || return 0
_cau_config_slurp _cau_config_slurp
# last assignment wins, matching the previous sed|tail behaviour # last assignment wins, matching the previous sed|tail behaviour
@@ -41,11 +47,14 @@ cau_config_get() {
val="${val%\"}" val="${val%\"}"
val="${val#\"}" val="${val#\"}"
if [[ -n $val ]]; then [[ -n $val ]] && CAU_CONFIG_VALUE="$val"
printf '%s\n' "$val" return 0
else }
printf '%s\n' "$default"
fi # cau_config_get <Key> [default]
cau_config_get() {
_cau_config_lookup "$@"
printf '%s\n' "$CAU_CONFIG_VALUE"
} }
# cau_config_bool <Key> <default: yes|no> # cau_config_bool <Key> <default: yes|no>
@@ -121,11 +130,10 @@ cau_config_load() {
CFG_NOTIFY_START=no; cau_config_bool NotifyOnStart yes && CFG_NOTIFY_START=yes 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_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_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="$(cau_config_get UpdateInterval 1d)"
CFG_INTERVAL_SECONDS="$(cau_duration_to_seconds "$CFG_INTERVAL" 86400)" CFG_INTERVAL_SECONDS="$(cau_duration_to_seconds "$CFG_INTERVAL" 86400)"
CFG_MIN_BATTERY="$(cau_config_int MinBatteryPercent 40)" CFG_MIN_BATTERY="$(cau_config_int MinBatteryPercent 30)"
CFG_KEEP_OLD="$(cau_config_int KeepOldPackages 3)" CFG_KEEP_OLD="$(cau_config_int KeepOldPackages 3)"
CFG_AUR_HELPER="$(cau_config_get AURHelper auto)" CFG_AUR_HELPER="$(cau_config_get AURHelper auto)"
CFG_IGNORE_PKG="$(cau_config_get IgnorePkg '')" CFG_IGNORE_PKG="$(cau_config_get IgnorePkg '')"
+73 -36
View File
@@ -131,11 +131,10 @@ CAU_SETTINGS=(
"NotifyOnStart|bool|yes|Notify when an update starts" "NotifyOnStart|bool|yes|Notify when an update starts"
"NotifyOnSuccess|bool|yes|Notify after a successful update" "NotifyOnSuccess|bool|yes|Notify after a successful update"
"NotifyOnError|bool|yes|Notify when something goes wrong" "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" "UpdateInterval|choice:6h 12h 1d 2d 1w|1d|Time between update runs"
"SkipWhenGaming|bool|yes|Postpone while a game is running" "SkipWhenGaming|bool|yes|Postpone while a game is running"
"RequireAC|bool|no|Only update on mains power" "RequireAC|bool|no|Only update on mains power"
"MinBatteryPercent|choice:0 20 30 40 50 60 70 80|40|Minimum battery level (%)" "MinBatteryPercent|choice:0 20 30 40 50 60 70 80|30|Minimum battery level (%)"
"UpdateAUR|bool|yes|Update AUR packages" "UpdateAUR|bool|yes|Update AUR packages"
"UpdateFlatpak|bool|yes|Update Flatpaks" "UpdateFlatpak|bool|yes|Update Flatpaks"
"UpdateAppImages|bool|yes|Update AppImages" "UpdateAppImages|bool|yes|Update AppImages"
@@ -156,25 +155,33 @@ _cau_is_true() {
} }
# _cau_setting_display <type> <value> # _cau_setting_display <type> <value>
# Result in CAU_SETTING_SHOWN. The three constant strings are resolved once by
# the caller into CAU_LBL_*; looking them up here would put a translation call
# on the per-line path.
CAU_SETTING_SHOWN=''
CAU_LBL_ON=''
CAU_LBL_OFF=''
CAU_LBL_NONE=''
_cau_setting_display() { _cau_setting_display() {
local type="$1" value="$2" local type="$1" value="$2"
case "$type" in case "$type" in
bool) bool)
if _cau_is_true "$value"; then if _cau_is_true "$value"; then
printf '%s%s%s' "$CAU_C_GREEN" "$(cau_msg "ON")" "$CAU_C_RESET" CAU_SETTING_SHOWN="${CAU_C_GREEN}${CAU_LBL_ON}${CAU_C_RESET}"
else else
printf '%s%s%s' "$CAU_C_DIM" "$(cau_msg "OFF")" "$CAU_C_RESET" CAU_SETTING_SHOWN="${CAU_C_DIM}${CAU_LBL_OFF}${CAU_C_RESET}"
fi fi
;; ;;
text) text)
if [[ -n $value ]]; then if [[ -n $value ]]; then
printf '%s' "$value" CAU_SETTING_SHOWN="$value"
else else
printf '%s%s%s' "$CAU_C_DIM" "$(cau_msg "(none)")" "$CAU_C_RESET" CAU_SETTING_SHOWN="${CAU_C_DIM}${CAU_LBL_NONE}${CAU_C_RESET}"
fi fi
;; ;;
*) printf '%s' "$value" ;; *) CAU_SETTING_SHOWN="$value" ;;
esac esac
} }
@@ -205,58 +212,88 @@ _cau_setting_cycle() {
# cau_ui_settings # cau_ui_settings
# A cursor list rather than a numbered menu: there are eighteen settings, and # A cursor list rather than a numbered menu: there are eighteen settings, and
# numbering them would run out of digits and force paging. # numbering them would run out of digits and force paging.
#
# The frame is assembled in memory and written once. Everything constant - the
# specs, the translated labels, the clear sequence - is resolved before the
# loop, and the values are re-read only after something actually changes.
# Drawing the naive way cost a command substitution per label per frame, which
# measured 435 ms per keypress: arrow keys felt like the console was reloading,
# because in effect it was.
cau_ui_settings() { cau_ui_settings() {
local cursor=0 key spec name type default label value line pad
local count=${#CAU_SETTINGS[@]} local count=${#CAU_SETTINGS[@]}
local -a names=() types=() defaults=() labels=()
local -a values=()
local spec name type default label locale i key frame row pad dirty=1 cursor=0
while true; do locale="$(cau_ui_locale)"
clear 2>/dev/null || true cau_msg_into "$locale" "ON"; CAU_LBL_ON="$CAU_MSG_RESULT"
cau_head " $(cau_msg "Settings")" cau_msg_into "$locale" "OFF"; CAU_LBL_OFF="$CAU_MSG_RESULT"
cau_msg_into "$locale" "(none)"; CAU_LBL_NONE="$CAU_MSG_RESULT"
local i=0
for spec in "${CAU_SETTINGS[@]}"; do for spec in "${CAU_SETTINGS[@]}"; do
IFS='|' read -r name type default label <<< "$spec" IFS='|' read -r name type default label <<< "$spec"
value="$(cau_config_get "$name" "$default")" names+=("$name"); types+=("$type"); defaults+=("$default")
cau_msg_into "$locale" "$label"
if (( i == cursor )); then labels+=("$CAU_MSG_RESULT")
line="${CAU_C_BLUE}▸${CAU_C_RESET} "
else
line=" "
fi
local text
text="$(cau_msg "$label")"
pad=$(( 42 - ${#text} ))
(( pad < 0 )) && pad=0
printf ' %s%s%*s %s\n' "$line" "$text" "$pad" '' \
"$(_cau_setting_display "$type" "$value")"
i=$(( i + 1 ))
done done
printf '\n %s%s%s\n' "$CAU_C_DIM" \ local title hint
"$(cau_msg "Up/Down select - Space or Right changes - q goes back")" "$CAU_C_RESET" cau_msg_into "$locale" "Settings"; title="$CAU_MSG_RESULT"
cau_msg_into "$locale" "Up/Down select - Space or Right changes - q goes back"
hint="$CAU_MSG_RESULT"
# the terminfo clear string, fetched once instead of forking per frame
local clearseq
clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J'
while true; do
if (( dirty )); then
for i in "${!names[@]}"; do
_cau_config_lookup "${names[i]}" "${defaults[i]}"
values[i]="$CAU_CONFIG_VALUE"
done
dirty=0
fi
frame="$clearseq"$'\n'"${CAU_C_BOLD}${CAU_C_BLUE} ${title}${CAU_C_RESET}"$'\n\n'
local marker selected="${CAU_C_BLUE}▸${CAU_C_RESET} "
for i in "${!names[@]}"; do
_cau_setting_display "${types[i]}" "${values[i]}"
pad=$(( 42 - ${#labels[i]} ))
(( pad < 0 )) && pad=0
if (( i == cursor )); then marker="$selected"; else marker=' '; fi
printf -v row ' %s%s%*s %s' \
"$marker" "${labels[i]}" "$pad" '' "$CAU_SETTING_SHOWN"
frame+="$row"$'\n'
done
frame+=$'\n'" ${CAU_C_DIM}${hint}${CAU_C_RESET}"$'\n'
printf '%s' "$frame"
key="$(cau_read_key)" || return 0 key="$(cau_read_key)" || return 0
IFS='|' read -r name type default label <<< "${CAU_SETTINGS[cursor]}" type="${types[cursor]}"
value="$(cau_config_get "$name" "$default")" name="${names[cursor]}"
case "$key" in case "$key" in
up|k) cursor=$(( (cursor - 1 + count) % count )) ;; up|k) cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) cursor=$(( (cursor + 1) % count )) ;; down|j) cursor=$(( (cursor + 1) % count )) ;;
space|enter|right|l) space|enter|right|l)
if [[ $type == text ]]; then if [[ $type == text ]]; then
cau_ui_edit_text "$name" "$value" cau_ui_edit_text "$name" "${values[cursor]}"
else else
cau_config_set "$name" "$(_cau_setting_cycle "$type" "$value" 1)" \ cau_config_set "$name" "$(_cau_setting_cycle "$type" "${values[cursor]}" 1)" \
|| { cau_bad "$(cau_msg "Could not write the configuration file.")"; cau_pause; } || { cau_bad "$(cau_msg "Could not write the configuration file.")"; cau_pause; }
fi fi
dirty=1
;; ;;
left|h) left|h)
[[ $type == text ]] || cau_config_set "$name" \ if [[ $type != text ]]; then
"$(_cau_setting_cycle "$type" "$value" -1)" \ cau_config_set "$name" "$(_cau_setting_cycle "$type" "${values[cursor]}" -1)" \
|| { cau_bad "$(cau_msg "Could not write the configuration file.")"; cau_pause; } || { cau_bad "$(cau_msg "Could not write the configuration file.")"; cau_pause; }
dirty=1
fi
;; ;;
q|Q|escape) return 0 ;; q|Q|escape) return 0 ;;
*) ;; *) ;;
+74 -19
View File
@@ -15,30 +15,68 @@
CAU_NOTIFY_ICON="system-software-update" CAU_NOTIFY_ICON="system-software-update"
CAU_NOTIFY_QUEUE_MAX=20 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 # Never fails: a machine without libnotify, or with nobody logged in, is a
# normal state, not an error. # normal state, not an error.
cau_notify() { cau_notify() {
cau_notify_tagged '' yes "$@" 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 # linger=yes keeps the message on screen until somebody dismisses it; anything
# rather than stacking a second bubble beside it - that is what turns # else lets it time out on its own. The line it draws is whether the machine
# "installing updates" into "system updated" in place instead of leaving two # still needs a person: a finished update is over and done with and should not
# messages that contradict each other. # 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. # 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. # Telling a user at next login that an update started an hour ago is noise.
cau_notify_tagged() { cau_notify_tagged() {
local tag="$1" queue="$2" urgency="$3" title="$4" body="$5" local tag="$1" queue="$2" urgency="$3" linger="$4" title="$5" body="$6"
shift 5 shift 6
local -a args=("$@") local -a args=("$@")
local delivered=0 user uid locale t b prev newid local delivered=0 user uid locale t b prev newid
[[ $CFG_NOTIFICATIONS == yes ]] || return 0 [[ $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 while read -r user uid; do
[[ -n $user ]] || continue [[ -n $user ]] || continue
cau_as_user "$user" "$uid" sh -c 'command -v notify-send >/dev/null' || 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")" t="$(cau_msg_in "$locale" "$title")"
b="$(cau_msg_in "$locale" "$body" "${args[@]}")" b="$(cau_msg_in "$locale" "$body" "${args[@]}")"
prev=0
if [[ -n $tag ]]; then if [[ -n $tag ]]; then
prev="$(cau_state_read "notify_id_${tag}_${user}" 0)" 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 fi
if newid="$(cau_as_user "$user" "$uid" notify-send \ if newid="$(cau_as_user "$user" "$uid" notify-send \
--app-name="$CAU_PRETTY" \ --app-name="$CAU_PRETTY" \
--icon="$CAU_NOTIFY_ICON" \ --icon="$CAU_NOTIFY_ICON" \
--urgency="$urgency" \ --urgency="$urgency" \
--print-id --replace-id="$prev" \ "${expiry[@]}" \
--print-id \
-- "$t" "$b" 2>/dev/null)" -- "$t" "$b" 2>/dev/null)"
then then
delivered=1 delivered=1
@@ -70,10 +111,10 @@ cau_notify_tagged() {
(( delivered )) && return 0 (( delivered )) && return 0
[[ $queue == yes ]] || 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: # Tab-separated records, oldest first. The file is world-readable on purpose:
# the login-time delivery runs unprivileged and only ever reads it. # 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 # progress by "last key seen", so two records sharing a key could make the
# second one unreachable forever if a login landed between them. # second one unreachable forever if a login landed between them.
cau_notify_enqueue() { cau_notify_enqueue() {
local urgency="$1" title="$2" body="$3" local urgency="$1" linger="$2" title="$3" body="$4"
shift 3 shift 4
local record tmp local record tmp
mkdir -p "$CAU_STATEDIR" 2>/dev/null || return 0 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 local arg
for arg in "$@"; do for arg in "$@"; do
record+=$'\t'"${arg//$'\t'/ }" 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, # 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. # so no write access to /var/lib is needed and each user is tracked separately.
cau_notify_deliver_queue() { cau_notify_deliver_queue() {
local seen_file seen ts urgency title body local seen_file seen ts urgency linger title body rest
local -a args local -a args expiry
[[ -r $CAU_NOTIFY_QUEUE ]] || return 0 [[ -r $CAU_NOTIFY_QUEUE ]] || return 0
cau_have notify-send || return 0 cau_have notify-send || return 0
@@ -123,20 +164,34 @@ cau_notify_deliver_queue() {
[[ $seen =~ ^[0-9]+$ ]] || seen=0 [[ $seen =~ ^[0-9]+$ ]] || seen=0
local newest="$seen" 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 =~ ^[0-9]+$ ]] || continue
(( ts > seen )) || 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 # remaining tab-separated fields are the body's printf arguments
args=() args=()
if [[ -n ${rest:-} ]]; then if [[ -n ${rest:-} ]]; then
IFS=$'\t' read -r -a args <<< "$rest" IFS=$'\t' read -r -a args <<< "$rest"
fi fi
expiry=(--expire-time=-1)
[[ $linger == yes ]] && expiry=(--expire-time=0)
notify-send \ notify-send \
--app-name="$CAU_PRETTY" \ --app-name="$CAU_PRETTY" \
--icon="$CAU_NOTIFY_ICON" \ --icon="$CAU_NOTIFY_ICON" \
--urgency="${urgency:-normal}" \ --urgency="${urgency:-normal}" \
"${expiry[@]}" \
-- "$(cau_msg "$title")" "$(cau_msg "$body" "${args[@]}")" 2>/dev/null || true -- "$(cau_msg "$title")" "$(cau_msg "$body" "${args[@]}")" 2>/dev/null || true
(( ts > newest )) && newest="$ts" (( ts > newest )) && newest="$ts"
+22
View File
@@ -43,6 +43,28 @@ cau_appimage_update() {
local user uid count rc=0 local user uid count rc=0
local -a cmd local -a cmd
# Is there a Gear Lever on this machine at all? Asked before the step is
# announced rather than discovered inside the loop: on a machine without
# one - the common case, it is an optional dependency - a step that exists
# only to hand its share of the bar straight to the next one is a jump the
# bar does not need. Stops at the first user who has it, so the extra probe
# costs anything only in the case it is there to remove.
local found=0
while read -r user uid; do
[[ -n $user ]] || continue
if _cau_gearlever_cmd "$user" "$uid" > /dev/null; then
found=1
break
fi
done < <(cau_active_session_users)
if (( ! found )); then
cau_progress_drop appimage
return 0
fi
cau_progress_step appimage "Updating AppImages"
while read -r user uid; do while read -r user uid; do
[[ -n $user ]] || continue [[ -n $user ]] || continue
+17 -1
View File
@@ -124,24 +124,40 @@ cau_aur_update() {
local pending failures local pending failures
local -a args local -a args
cau_aur_ready || return 0 # No helper, no base-devel, no bar: a step that cannot run should not be
# holding a share of it.
cau_aur_ready || { cau_progress_drop aur; return 0; }
cau_progress_step aur "Updating AUR packages"
pending="$(cau_aur_pending)" pending="$(cau_aur_pending)"
if (( pending == 0 )); then if (( pending == 0 )); then
cau_info "No AUR updates pending" cau_info "No AUR updates pending"
cau_state_clear aur_failures cau_state_clear aur_failures
cau_progress_drop aur
return 0 return 0
fi fi
cau_info "Updating $pending AUR package(s) with $CAU_AUR_HELPER" cau_info "Updating $pending AUR package(s) with $CAU_AUR_HELPER"
# The helper builds each package from source and prints plenty about it,
# none of it countable from this side. A single large package can take ten
# minutes, so the bar creeps through the step rather than sitting at its
# start for all of them; the item count stays where it is, because that is
# the number that would be lying if it moved.
cau_progress_item 0 "$pending"
mapfile -t args < <(cau_aur_helper_args) mapfile -t args < <(cau_aur_helper_args)
cau_progress_creep_start
if cau_run_logged cau_as_build_user "$CAU_AUR_HELPER" "${args[@]}"; then if cau_run_logged cau_as_build_user "$CAU_AUR_HELPER" "${args[@]}"; then
cau_progress_creep_stop
CAU_AUR_COUNT="$pending" CAU_AUR_COUNT="$pending"
cau_progress_item "$pending"
cau_state_clear aur_failures cau_state_clear aur_failures
return 0 return 0
fi fi
cau_progress_creep_stop
CAU_AUR_COUNT=0 CAU_AUR_COUNT=0
failures="$(cau_state_read aur_failures 0)" failures="$(cau_state_read aur_failures 0)"
[[ $failures =~ ^[0-9]+$ ]] || failures=0 [[ $failures =~ ^[0-9]+$ ]] || failures=0
+16 -2
View File
@@ -26,17 +26,31 @@ cau_flatpak_pending_system() {
cau_flatpak_update() { cau_flatpak_update() {
local rc=0 pending user uid home count local rc=0 pending user uid home count
cau_have flatpak || return 0 cau_have flatpak || { cau_progress_drop flatpak; return 0; }
# refresh appstream metadata first so remote-ls sees current versions cau_progress_step flatpak "Updating Flatpak apps"
# refresh appstream metadata first so remote-ls sees current versions.
# Nothing is countable until that has finished, so the bar creeps rather
# than waiting at the start of the step for it.
cau_progress_creep_start
cau_run_logged flatpak update --appstream --system --noninteractive || true cau_run_logged flatpak update --appstream --system --noninteractive || true
cau_progress_creep_stop
pending="$(cau_flatpak_pending_system)" pending="$(cau_flatpak_pending_system)"
if (( pending > 0 )); then if (( pending > 0 )); then
cau_info "Updating $pending system Flatpak(s)" cau_info "Updating $pending system Flatpak(s)"
cau_progress_item 0 "$pending"
# Started after the count above and stopped before the one below, so the
# only reports this side makes while a ticker is running are further
# along than the ticker ever gets.
cau_progress_creep_start
if cau_run_logged flatpak update --system --noninteractive --assumeyes; then if cau_run_logged flatpak update --system --noninteractive --assumeyes; then
cau_progress_creep_stop
CAU_FLATPAK_COUNT=$(( CAU_FLATPAK_COUNT + pending )) CAU_FLATPAK_COUNT=$(( CAU_FLATPAK_COUNT + pending ))
cau_progress_item "$pending"
else else
cau_progress_creep_stop
cau_warn "System Flatpak update failed" cau_warn "System Flatpak update failed"
rc=1 rc=1
fi fi
+198 -5
View File
@@ -27,6 +27,151 @@ cau_pacman_flags() {
done done
} }
# The verbs pacman puts in front of a package as it works through a
# transaction. Matched against English on purpose: the runner forces LC_ALL=C
# precisely so pacman's output stays parseable.
CAU_PACMAN_OP_RE='^(\([[:space:]]*[0-9]+/[0-9]+\) )?(upgrading|installing|reinstalling|downgrading|removing) [^[:space:]]+'
# Before any of that, everything has to be fetched, and on a domestic line
# that is the longer half of the run: two hundred packages take minutes to
# arrive and seconds to unpack. pacman prints one line per package while it
# does it,
#
# glibc-2.44+r24+g16be1518495f-1-x86_64_v3 downloading...
#
# and nothing else - no counter, no total - so the position here is counted the
# same way the transaction is.
#
# The database sync a few lines earlier prints the very same shape (" core
# downloading..."), and pacman strips the suffix that would tell a database
# from a package, so the count begins only after the header that separates the
# two phases.
CAU_PACMAN_DL_AWK='
/^:: Retrieving packages/ { retrieving = 1; next }
retrieving && / downloading\.\.\.$/ { n++; name = $1 }
END { print n + 0, name }'
# _cau_pacman_progress_watch <logfile>
# Feeds the desktop's progress bar by watching pacman work, through the three
# phases a pacman run has: first it works out what the upgrade consists of,
# then everything is fetched, then everything is unpacked. Three steps on the
# bar rather than one, because they are three stretches to sit through - and
# each one is silent in its own way.
#
# The first is the one that used to look like a hang. Between "starting full
# system upgrade" and the transaction it eventually prepares, pacman says
# nothing whatsoever, and on a large backlog that silence is minutes long. The
# only honest thing to show there is a label and no counter at all: a tally
# frozen at "0 of 161" reads as a stuck update, where "Preparing the update"
# with no number reads as what it actually is.
#
# In the transaction, pacman announces each package twice over, in one of two
# shapes, and which one depends on a flag this program sets itself:
#
# upgrading glibc... with --noprogressbar, i.e. every timer run
# ( 12/218) upgrading glibc [##] with the bar, i.e. an interactive `run`
#
# Only the second carries a counter, and the unattended runs that this bar
# exists for are exactly the ones that do not get it. So the position is
# counted here instead - one line per package - and the total taken from the
# "Package (218)" header pacman prints before it starts. That header is the
# better number anyway: checkupdates counts packages with an update available
# and knows nothing about the new dependencies pulled in alongside them.
#
# What must not be counted is the other (n/m) sequence pacman prints, for
# hooks and for checking keys, integrity and file conflicts. Each of those runs
# up to its own total, so following them would drive the bar to the end several
# times before the first package was unpacked. Requiring one of the verbs above
# is what excludes them.
#
# Read by polling the log 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 the second of latency it costs.
_cau_pacman_progress_watch() {
local log="$1"
local total="${CAU_PACMAN_COUNT:-0}" announced processed line pkg last=''
local phase=resolve fetched shown='' labelled=''
local t0=$SECONDS
while :; do
sleep 1
announced="$(grep -aoE '^Packages? \([0-9]+\)' "$log" 2>/dev/null \
| head -n1 | grep -oE '[0-9]+')"
[[ $announced =~ ^[0-9]+$ ]] && (( announced > 0 )) && total="$announced"
line="$(grep -aoE "$CAU_PACMAN_OP_RE" "$log" 2>/dev/null | tail -n1)"
if [[ -n $line ]]; then
# Unpacking has started, so whatever came before it is over. If
# nothing was ever retrieved - every package already sitting in the
# cache, which is the ordinary state of affairs after a run that was
# interrupted once already - then the download step never happened,
# and it is dropped rather than handed its whole share of the bar in
# exchange for no work at all.
if [[ $phase != install ]]; then
[[ $phase == download ]] || cau_progress_drop download
phase=install
cau_progress_step repo "Updating system packages" "$total"
fi
# Nothing new since the last look. Checked before the counting grep
# because on a large upgrade this loop spends most of its life here.
[[ $line != "$last" ]] || continue
last="$line"
processed="$(grep -acE "$CAU_PACMAN_OP_RE" "$log" 2>/dev/null)"
[[ $processed =~ ^[0-9]+$ ]] || continue
# Where pacman does carry a counter, believe it over the tally: it
# is the same number, but it also knows the true total.
if [[ $line =~ ^\([[:space:]]*([0-9]+)/([0-9]+)\) ]]; then
processed="${BASH_REMATCH[1]}"
total="${BASH_REMATCH[2]}"
fi
cau_progress_item "$processed" "$total"
pkg="${line##* }"
cau_progress_detail "Package" "${pkg%...}"
continue
fi
# Fetching. The header is what tells this apart from the database sync
# a few lines earlier, which prints the very same shape.
if grep -qa '^:: Retrieving packages' "$log" 2>/dev/null; then
if [[ $phase == resolve ]]; then
phase=download
cau_progress_step download "Downloading updates" "$total"
fi
read -r fetched pkg < <(awk "$CAU_PACMAN_DL_AWK" "$log" 2>/dev/null)
[[ $fetched =~ ^[0-9]+$ ]] || continue
[[ $fetched != "$shown" ]] || continue
shown="$fetched"
cau_progress_item "$fetched" "$total"
# Down to the bare name, as the transaction reports it: the file
# pacman names here carries version, release and architecture.
[[ -n $pkg ]] && cau_progress_detail "Package" "${pkg%-*-*-*}"
continue
fi
# Still resolving. pacman does mark the point where it stops syncing
# databases and starts working out the upgrade, and that is the half
# worth naming, because it is the half that takes the minutes.
if [[ $phase == resolve && $labelled != upgrade ]] \
&& grep -qa '^:: Starting full system upgrade' "$log" 2>/dev/null; then
labelled=upgrade
cau_progress_step resolve "Preparing the update"
fi
# Nothing countable happens in here at all, so the bar creeps instead.
# This is the stretch that used to look like a hung update.
cau_progress_creep $(( SECONDS - t0 ))
done
}
# _cau_pacman_exec <logfile> <pacman args...> # _cau_pacman_exec <logfile> <pacman args...>
# Captures pacman's output for classification, and streams it as well when a # Captures pacman's output for classification, and streams it as well when a
# person is watching. Upgrading a few hundred packages takes minutes; without # person is watching. Upgrading a few hundred packages takes minutes; without
@@ -35,13 +180,38 @@ cau_pacman_flags() {
_cau_pacman_exec() { _cau_pacman_exec() {
local log="$1" local log="$1"
shift shift
local rc watcher=0
if [[ -n $CAU_INTERACTIVE ]]; then # Only worth a second process and a grep per second if a bar exists to feed.
pacman "$@" 2>&1 | tee "$log" if cau_progress_active; then
return "${PIPESTATUS[0]}" _cau_pacman_progress_watch "$log" &
watcher=$!
fi fi
pacman "$@" > "$log" 2>&1 # Line-buffered on purpose. pacman writes to a file or through a pipe here,
# never to a terminal, so libc buffers it in 4KB blocks - and 4KB of
# "upgrading foo..." is on the order of a hundred and sixty packages. The
# watcher would see nothing at all, then a hundred and sixty lines at once,
# which is exactly how a bar comes to sit still and then leap to the end.
# Guarded rather than assumed: without coreutils there is no bar to feed
# either, but there is still an update to run.
local -a buffered=()
cau_have stdbuf && buffered=(stdbuf -oL)
if [[ -n $CAU_INTERACTIVE ]]; then
"${buffered[@]}" pacman "$@" 2>&1 | tee "$log"
rc="${PIPESTATUS[0]}"
else
"${buffered[@]}" pacman "$@" > "$log" 2>&1
rc=$?
fi
if (( watcher )); then
kill "$watcher" 2>/dev/null
wait "$watcher" 2>/dev/null
fi
return "$rc"
} }
# cau_pacman_pending # cau_pacman_pending
@@ -117,8 +287,18 @@ cau_pacman_update() {
local log kind local log kind
local -a flags local -a flags
# checkupdates goes first, against its own private database, and the bar
# says so rather than naming a step that has not begun. The watcher takes
# over from here and moves on to the download and repo steps as pacman
# actually reaches them.
cau_progress_step resolve "Checking for updates"
if ! cau_pacman_pending; then if ! cau_pacman_pending; then
cau_info "No repository updates pending" cau_info "No repository updates pending"
# Neither of the two steps this would have led to is going to happen,
# so the rest of the run gets their share of the bar instead of
# watching it jump 70% the moment Flatpaks start.
cau_progress_drop download repo
return 0 return 0
fi fi
@@ -126,6 +306,10 @@ cau_pacman_update() {
CAU_PACMAN_COUNT="$(grep -c . <<< "$CAU_PACMAN_PENDING")" CAU_PACMAN_COUNT="$(grep -c . <<< "$CAU_PACMAN_PENDING")"
[[ $CAU_PACMAN_COUNT =~ ^[0-9]+$ ]] || CAU_PACMAN_COUNT=0 [[ $CAU_PACMAN_COUNT =~ ^[0-9]+$ ]] || CAU_PACMAN_COUNT=0
cau_info "Updating $CAU_PACMAN_COUNT repository package(s)" cau_info "Updating $CAU_PACMAN_COUNT repository package(s)"
# Deliberately no item count yet. Until pacman has prepared a
# transaction there is nothing being worked through, and "0 of 161"
# against a bar that cannot move for the next few minutes is the exact
# impression the resolve step exists to avoid.
else else
# checkupdates is unavailable, so the list is unknown and pacman is # checkupdates is unavailable, so the list is unknown and pacman is
# asked to work it out itself. # asked to work it out itself.
@@ -141,7 +325,7 @@ cau_pacman_update() {
# updates and, on a German system, is not even translated. Somebody who was # updates and, on a German system, is not even translated. Somebody who was
# simply told beforehand does not end up staring at that. # simply told beforehand does not end up staring at that.
if [[ $CFG_NOTIFY_START == yes ]]; then if [[ $CFG_NOTIFY_START == yes ]]; then
cau_notify_tagged run no normal \ cau_notify_tagged run no normal no \
"Installing updates" \ "Installing updates" \
"%d packages are being updated. Please leave the computer switched on until this is done." \ "%d packages are being updated. Please leave the computer switched on until this is done." \
"${CAU_PACMAN_COUNT:-0}" "${CAU_PACMAN_COUNT:-0}"
@@ -158,6 +342,13 @@ cau_pacman_update() {
while true; do while true; do
if _cau_pacman_exec "$log" -Syu "${flags[@]}" "${extra[@]}"; then if _cau_pacman_exec "$log" -Syu "${flags[@]}" "${extra[@]}"; then
# The watcher decided the same thing in a subshell, so a step it
# dropped is still in the plan out here. Same question, same answer,
# and the two copies agree on what the rest of the run is scaled
# against. Only on the way out: a failed attempt is about to be
# retried, and that retry may well download after all.
grep -qa '^:: Retrieving packages' "$log" 2>/dev/null \
|| cau_progress_drop download
cat "$log" >> "$CAU_RUNLOG" 2>/dev/null cat "$log" >> "$CAU_RUNLOG" 2>/dev/null
grep -E '^(removing|replacing) ' "$log" 2>/dev/null \ grep -E '^(removing|replacing) ' "$log" 2>/dev/null \
| while read -r line; do cau_info " $line"; done | while read -r line; do cau_info " $line"; done
@@ -258,6 +449,8 @@ cau_pacman_pacnew_count() {
cau_pacman_cleanup() { cau_pacman_cleanup() {
local -a orphans local -a orphans
cau_progress_step cleanup "Cleaning up after the update"
if [[ $CFG_REMOVE_ORPHANS == yes ]]; then if [[ $CFG_REMOVE_ORPHANS == yes ]]; then
mapfile -t orphans < <(pacman -Qtdq 2>/dev/null) mapfile -t orphans < <(pacman -Qtdq 2>/dev/null)
if (( ${#orphans[@]} )); then if (( ${#orphans[@]} )); then
+502
View File
@@ -0,0 +1,502 @@
# 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: the repositories dominate - fetching them and unpacking
# them about equally, on a domestic line - and 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.
#
# "resolve" is everything pacman does before it has a transaction: syncing the
# databases and working out what the upgrade actually consists of. It is
# usually seconds, which is why it is worth so little - but on a large backlog
# it is minutes, and those minutes used to be spent looking at a bar that had
# not moved yet.
declare -A CAU_PROGRESS_WEIGHTS=(
[resolve]=10 [download]=30 [repo]=40 [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_drop <step-id...>
# Takes steps out of the plan and rescales the bar to what is left.
#
# Which steps a run will perform is only half known up front. The other half
# turns up while it runs: nothing to download because every package was already
# in the cache, no AUR updates pending, no Flatpaks installed. A step like that
# keeps its whole share of the bar and then hands it over in a single jump the
# moment the next one starts - which is precisely the stutter this is here to
# remove. Dropping it hands its share to the steps that do have work instead,
# so the bar advances at a steady pace rather than leaping across the gaps.
#
# It is also what lets the weights above stay rough: they never have to be
# right about a step that does not run, only about the ones that do.
#
# Only ever called for a step that has not started, so nothing already behind
# the bar is rescaled and the bar does not travel backwards.
cau_progress_drop() {
local drop step
local -a kept=()
(( ${#CAU_PROGRESS_FDS[@]} )) || return 0
for step in "${CAU_PROGRESS_PLAN[@]}"; do
for drop in "$@"; do
[[ $step == "$drop" ]] && continue 2
done
kept+=("$step")
done
(( ${#kept[@]} == ${#CAU_PROGRESS_PLAN[@]} )) && return 0
CAU_PROGRESS_PLAN=("${kept[@]}")
CAU_PROGRESS_SCALE=0
for step in "${CAU_PROGRESS_PLAN[@]}"; do
CAU_PROGRESS_SCALE=$(( CAU_PROGRESS_SCALE + ${CAU_PROGRESS_WEIGHTS[$step]:-0} ))
done
# Nothing left to weigh against would divide by zero further down. Cannot
# happen while cleanup is unconditional, but this is cheaper than relying
# on that staying true.
(( CAU_PROGRESS_SCALE > 0 )) || CAU_PROGRESS_SCALE=1
}
# 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
local found=0
for step in "${CAU_PROGRESS_PLAN[@]}"; do
[[ $step == "$id" ]] && { found=1; break; }
base=$(( base + ${CAU_PROGRESS_WEIGHTS[$step]:-0} ))
done
# A step that was dropped for having no work is not a step to move to.
# Without this the loop above would fall off the end of the plan and hand
# back the sum of every weight, i.e. send the bar straight to 100%.
(( found )) || return 0
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_pct <numerator> <denominator>
# How far through the current step we are, as a share of its span, turned into
# one number for the whole run and sent on if it has moved.
_cau_progress_pct() {
local num="$1" den="$2" pct scaled
if (( den > 0 )); then
scaled=$(( CAU_PROGRESS_BASE * 100 + CAU_PROGRESS_SPAN * 100 * num / den ))
else
scaled=$(( CAU_PROGRESS_BASE * 100 ))
fi
pct=$(( scaled / CAU_PROGRESS_SCALE ))
(( pct > 100 )) && pct=100
# Never backwards. Two honest things can ask for that: dropping a step
# rescales the run against a smaller total, and the conflict-recovery loop
# restarts pacman - and with it the item tally - from the top. Both are
# real, neither is a reason to show somebody a bar that retreats.
(( pct < CAU_PROGRESS_SHOWN )) && pct=$CAU_PROGRESS_SHOWN
# 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_item <processed> [total]
# How far through the current step we are.
cau_progress_item() {
local processed="$1" total="${2:-$CAU_PROGRESS_TOTAL}"
(( ${#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"
_cau_progress_pct "$processed" "$total"
else
_cau_progress_pct 0 0
fi
}
# cau_progress_creep <seconds-elapsed>
# Moves the bar through a step whose length cannot be known in advance.
#
# Some of a run has no counter to offer and never will. pacman prints nothing
# whatsoever between "starting full system upgrade" and the transaction it
# eventually prepares; an AUR helper compiling a package prints plenty, none of
# it countable. On a large backlog either is minutes. There is no honest number
# to show for that - but a bar that has not moved since it appeared is read as
# a hang, and somebody who reads it that way reaches for the power button in
# the middle of an update. That is the failure this is here to prevent.
#
# So it creeps, along a curve that approaches the end of the step without ever
# reaching it: half the step's share after HALFLIFE seconds, three quarters
# after three times that, the whole of it never. Nothing is claimed that is not
# known - the item counter stays empty throughout, which is the field that
# would be lying if it moved - and the step still finishes the instant real
# work reports in, because every real report is further along than the creep.
#
# Confined to the step's own span, so a creep can never overtake the step that
# comes after it however long it is left running.
CAU_PROGRESS_CREEP_HALFLIFE=45
cau_progress_creep() {
local elapsed="$1"
(( ${#CAU_PROGRESS_FDS[@]} )) || return 0
[[ $elapsed =~ ^[0-9]+$ ]] || return 0
_cau_progress_pct "$elapsed" $(( elapsed + CAU_PROGRESS_CREEP_HALFLIFE ))
}
# cau_progress_creep_start / cau_progress_creep_stop
# The same, for a step that blocks in one long call instead of polling: the
# ticker runs alongside it and is stopped when it returns. Only one at a time,
# and starting a second one replaces the first.
CAU_PROGRESS_CREEP_PID=0
CAU_PROGRESS_CREEP_T0=0
cau_progress_creep_start() {
cau_progress_creep_stop
(( ${#CAU_PROGRESS_FDS[@]} )) || return 0
CAU_PROGRESS_CREEP_T0=$SECONDS
local t0=$SECONDS
{
# Waiting without forking a sleep every two seconds, for the same
# reason cau_progress_begin opens its fifo read-write: a pipe held open
# at both ends never reports end-of-file, so a timed read on it blocks
# for exactly the timeout and nothing else. A forked sleep would also
# survive the kill below - it is a child of this subshell, not this
# subshell - and inherit the fifo's write end, which would keep the
# helper from seeing the end of its input until the sleep ran out.
local nap
exec {nap}<> <(:)
while :; do
read -r -t 2 -u "$nap" _ || true
cau_progress_creep $(( SECONDS - t0 ))
done
} &
CAU_PROGRESS_CREEP_PID=$!
}
cau_progress_creep_stop() {
(( CAU_PROGRESS_CREEP_PID )) || return 0
kill "$CAU_PROGRESS_CREEP_PID" 2>/dev/null
wait "$CAU_PROGRESS_CREEP_PID" 2>/dev/null
CAU_PROGRESS_CREEP_PID=0
# The ticker moved the bar from inside a subshell, so this side never saw
# it happen and still believes the bar is where it was left. Catching up
# costs one recomputation - the curve is a function of elapsed time and
# nothing else - and without it the next ordinary report from here would be
# measured against a stale percentage and send the bar backwards.
cau_progress_creep $(( SECONDS - CAU_PROGRESS_CREEP_T0 ))
}
# 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
}
# How many lines of the run log the job entry carries, and how wide each one
# is allowed to be. Both are display limits rather than arbitrary ones: five
# lines is about what fits under a notification popup before it starts pushing
# the buttons off the bottom, and a line long enough to be elided anyway is
# only costing room in the pipe. Together they also keep one instruction well
# inside PIPE_BUF, which is what makes the write atomic against the runner
# writing its own progress down the same pipe.
CAU_PROGRESS_TAIL_LINES=5
CAU_PROGRESS_TAIL_COLS=120
CAU_PROGRESS_TAIL_PID=0
# cau_progress_log <label-msgid> <text>
# The second description field, holding the last few lines of the run log.
#
# The protocol is one instruction per line, so the tail travels tab separated
# and is put back together on the other side. Tabs inside a log line would
# split it in two on the way, so they become spaces first - the job view
# renders either as whitespace, and a line broken in half renders as nonsense.
cau_progress_log() {
local label="$1" text="$2" i fd
(( ${#CAU_PROGRESS_FDS[@]} )) || return 0
text="${text//$'\t'/ }"
text="${text//$'\n'/$'\t'}"
for i in "${!CAU_PROGRESS_FDS[@]}"; do
fd="${CAU_PROGRESS_FDS[$i]}"
[[ -n $fd ]] || continue
cau_msg_into "${CAU_PROGRESS_LOCALES[$i]}" "$label"
printf 'log\t%s\t%s\n' "$CAU_MSG_RESULT" "$text" >&"$fd" 2>/dev/null || true
done
}
# cau_progress_tail_start <logfile> / cau_progress_tail_stop
# Follows the run log for as long as the update lasts, so expanding "Details"
# shows what the update is actually doing right now.
#
# This is the answer to the part of a run that has no counter and never will:
# an AUR helper compiling for a quarter of an hour says plenty about what it is
# up to, none of it countable, and all of it going into a log file nobody is
# looking at. The percentage says the update is alive; these lines say what it
# is alive doing.
#
# Polled rather than followed with tail -F, for the same reason the pacman
# watcher polls: only the last few lines are ever displayed, so every line in
# between is work nobody would see. Re-read whole and compared, which also
# makes truncation and rotation of the log a non-event.
cau_progress_tail_start() {
local file="$1"
cau_progress_tail_stop
(( ${#CAU_PROGRESS_FDS[@]} )) || return 0
cau_have tail || return 0
{
local nap last='' now
exec {nap}<> <(:)
while :; do
read -r -t 2 -u "$nap" _ || true
now="$(tail -n "$CAU_PROGRESS_TAIL_LINES" "$file" 2>/dev/null \
| cut -c "1-${CAU_PROGRESS_TAIL_COLS}")"
[[ -n $now && $now != "$last" ]] || continue
last="$now"
cau_progress_log "Log" "$now"
done
} &
CAU_PROGRESS_TAIL_PID=$!
}
cau_progress_tail_stop() {
(( CAU_PROGRESS_TAIL_PID )) || return 0
kill "$CAU_PROGRESS_TAIL_PID" 2>/dev/null
wait "$CAU_PROGRESS_TAIL_PID" 2>/dev/null
CAU_PROGRESS_TAIL_PID=0
}
# 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
# Before the descriptors go: anything still running in the background holds
# its own copy of them, so the helper would not see the end of its input
# until it exited - and it is about to be waited on.
cau_progress_creep_stop
cau_progress_tail_stop
# 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
}