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

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

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

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

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

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

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
#
# cachy-auto-update-progress - the update's progress bar on the desktop
#
# Started by the update runner, once per logged-in user, inside that user's
# session. Reads one instruction per line on stdin and turns it into the same
# progress entry the desktop shows while Dolphin copies files:
#
# info<TAB>text headline, already translated by the caller
# detail<TAB>name<TAB>value a labelled line under "Details"
# total<TAB>n how many items this step has
# done<TAB>n how many of them are finished
# percent<TAB>n overall progress, 0-100
# end[<TAB>message] finish; a message marks the job as failed
#
# Why a separate process at all: the desktop ties the progress entry to the
# D-Bus connection that asked for it and withdraws the entry the moment that
# connection goes away. One-shot callers - gdbus, busctl, dbus-send - therefore
# cannot drive one, because each invocation is its own connection that closes
# again immediately. So something has to sit there and hold the connection open
# for as long as the update takes, and read its orders from somewhere else.
#
# Copyright (C) 2026 Felitendo
# SPDX-License-Identifier: GPL-3.0-or-later
import sys
try:
from gi.repository import Gio, GLib
except ImportError:
# No GLib bindings: the update itself is unaffected, there is just no bar.
# Stdin is still drained, because the runner writes into a pipe and a
# reader that walks away would eventually block the update behind a full
# pipe buffer.
for _ in sys.stdin:
pass
sys.exit(0)
JOB_SERVICE = "org.kde.JobViewServer"
JOB_PATH = "/JobViewServer"
DESKTOP_ENTRY = "cachy-auto-update"
class Job:
"""The progress entry, or a do-nothing stand-in where there is no desk."""
def __init__(self):
self.bus = None
self.path = None
def open(self):
self.bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
reply = self.bus.call_sync(
JOB_SERVICE, JOB_PATH, "org.kde.JobViewServerV2", "requestView",
# capabilities 0: no cancel and no pause button. Neither can be
# honoured - pacman's commit phase is not interruptible - and a
# button that does nothing is worse than no button.
GLib.Variant("(sia{sv})", (DESKTOP_ENTRY, 0, {})),
GLib.VariantType("(o)"), Gio.DBusCallFlags.NONE, -1, None)
self.path = reply.unpack()[0]
def call(self, method, variant):
if self.path is None:
return
try:
self.bus.call_sync(
JOB_SERVICE, self.path, "org.kde.JobViewV2", method, variant,
None, Gio.DBusCallFlags.NONE, -1, None)
except GLib.Error:
# The desktop went away mid-update - a logout, or a plasmashell
# restart. The update carries on without a bar.
self.path = None
def close(self, message=""):
# Always terminate explicitly. A job whose owner simply disappears is
# reported by the desktop as "the application closed unexpectedly",
# which would turn every successful update into a failure notice.
#
# The error code is what decides how the entry is labelled; passing a
# message to terminate() on its own still files the job as completed,
# which next to "the update was stopped before it finished" reads as a
# contradiction.
#
# 100 is KJob::UserDefinedError, and the value does matter: 1 is
# KJob::KilledJobError, which the desktop discards without showing
# anything on the grounds that whoever killed the job already knows.
if message:
self.call("setError", GLib.Variant("(u)", (100,)))
self.call("terminate", GLib.Variant("(s)", (message,)))
self.path = None
def main():
job = Job()
try:
job.open()
except GLib.Error:
# No job server on this desktop - anything that is not Plasma. Same
# deal as a missing binding: drain stdin, stay out of the way.
for _ in sys.stdin:
pass
return 0
unit = "items"
try:
for line in sys.stdin:
fields = line.rstrip("\n").split("\t")
cmd = fields[0]
arg = fields[1] if len(fields) > 1 else ""
if cmd == "info":
job.call("setInfoMessage", GLib.Variant("(s)", (arg,)))
elif cmd == "detail" and len(fields) > 2:
job.call("setDescriptionField",
GLib.Variant("(uss)", (0, arg, fields[2])))
elif cmd == "total":
job.call("setTotalAmount", GLib.Variant("(ts)", (int(arg), unit)))
elif cmd == "done":
job.call("setProcessedAmount", GLib.Variant("(ts)", (int(arg), unit)))
elif cmd == "percent":
job.call("setPercent", GLib.Variant("(u)", (int(arg),)))
elif cmd == "end":
job.close(arg)
break
except (ValueError, IndexError):
# A malformed line is a bug on the writing side, not a reason to leave
# a stuck progress bar on somebody's desktop.
pass
except KeyboardInterrupt:
pass
finally:
job.close()
return 0
if __name__ == "__main__":
sys.exit(main())