Compare commits

...
3 Commits
Author SHA1 Message Date
Felitendo c308b7e286 Add a settings screen so nothing needs a text editor
All eighteen options are now reachable from the menu as a cursor list: arrows
select, Space or Right cycles a value, q goes back, changes are written
immediately. A numbered menu would have run out of digits.

Three real bugs surfaced while building it, each found by testing against an
actual pty rather than a pipe:

- Labels went through printf as format strings, so the percent sign in
  "Minimum battery level (%)" was an invalid conversion. cau_msg_in now only
  treats a message as a format string when arguments were actually passed -
  otherwise any literal % a translator writes is a trap.

- Mixing bash's line-mode read into a single-key interface left the following
  read -sn1 receiving nothing at all, reproducibly, so the screen froze after
  editing the package list. Replaced with a small line editor built on the same
  single-character reader.

- Backspace was being swallowed: in canonical mode DEL is the ERASE character
  and the line discipline consumes it, and bash returns to canonical mode
  between each read -sn1. The interface now holds non-canonical mode for its
  whole lifetime and hands the terminal back only around actions that print or
  prompt, with a trap restoring it on Ctrl-C.

Translations and config reads are memoized; the screen redraws every label on
every keypress and a fork per lookup was the reason the redraw was slow enough
to matter.
2026-08-08 16:18:59 +02:00
Felitendo 2eff62f9fd Say an update is running before it starts, not only after
Asked what happens if someone shuts the laptop down mid-update, the honest
answer turned out to be poor. The inhibitor does stop them - logind refuses and
falls back to org.freedesktop.login1.power-off-ignore-inhibit, which is
auth_admin_keep - so the desktop answers with an administrator password prompt
reading "Power off the system while an application is inhibiting this". systemd
ships that string untranslated, it never mentions updates, and no KDE catalog
contains any shutdown-blocked text at all. Only systemctl names the reason.

So a notification now goes out before the transaction, asking for the machine to
be left on. Notifications gained a tag: the result replaces the start message in
place rather than stacking a second, contradictory bubble beside it.

Tagged notifications also carry a queue flag. "An update is starting" is only
meaningful while somebody is looking, so unlike the result it is not spooled for
delivery at next login.
2026-08-08 15:48:55 +02:00
Felitendo 0f91a79ba6 Recover from a pacman lock left behind by a power cut
A machine switched off mid-update leaves /var/lib/pacman/db.lck behind. Nothing
removed it, so every subsequent run deferred on it - one power cut would have
stopped updates permanently and silently, which on an unattended machine is the
worst outcome there is.

A lock older than the current boot is provably abandoned: no process that could
hold it still exists. Those are now removed and the interrupted upgrade is
repeated, with pacman reinstalling anything caught half-written. A lock that is
merely unheld within the same boot stays untouched and is only reported, since
removing it could corrupt a live transaction; the boot-time test is what makes
the difference between a proof and a guess. A fuser check is kept alongside it
so a backwards clock jump cannot make a live lock look abandoned.

Documented what each layer can actually promise: suspend and normal shutdown
are blocked by the existing inhibitor, a hard power-off cannot be prevented by
anything, and snap-pac's pre/post snapshots remain the backstop.
2026-08-08 15:40:07 +02:00
13 changed files with 698 additions and 35 deletions

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@
# Overridable so a packager can pass the version it is actually building
# (`make VERSION=$pkgver`). The literal below is the fallback for builds
# straight from a checkout, and is what a release tag has to carry.
VERSION ?= 1.0.7
VERSION ?= 1.1.0
PREFIX ?= /usr
DESTDIR ?=
+47
View File
@@ -30,9 +30,14 @@ That opens a menu with the two switches there are:
[3] Update now
[4] Show log
[5] Show current conditions
[6] Settings
[q] Quit
```
Everything is configurable from **[6] Settings** — a cursor list covering all
eighteen options, so nothing needs a text editor. Arrow keys select, Space or
Right changes a value, `q` goes back; changes are written immediately.
The interface is fully translated; on a German system everything above appears
in German.
@@ -123,6 +128,48 @@ A leftover `db.lck` from a crashed transaction is never deleted automatically
guessing wrong there corrupts a live transaction. After it has been seen
unheld on several consecutive runs, you get a notification instead.
## What if the machine is switched off mid-update
Three layers, in order of how much they can actually promise:
**A notification goes out before the transaction starts** — "Installing
updates, please leave the computer switched on until this is done" — and is
replaced in place by the result when the run finishes, so it costs one bubble
rather than two. This exists because of what the next paragraph does *not* do.
**Suspend and a normal shutdown are blocked.** The run holds a
`systemd-inhibit --what=sleep:shutdown --mode=block` lock, so closing the lid or
picking "Shut down" cannot interrupt a transaction. Be aware of what that looks
like, though: logind refuses the request and requires the polkit action
`org.freedesktop.login1.power-off-ignore-inhibit`, which is `auth_admin_keep`.
The desktop therefore answers a shutdown attempt with an **administrator
password prompt** reading *"Power off the system while an application is
inhibiting this"* — a string systemd ships untranslated, and one that never
mentions updates. No KDE dialog explains the situation. Only `systemctl
poweroff` in a terminal names the reason. That prompt is exactly why the
notification above is on by default.
**A hard power-off cannot be prevented by anything.** Holding the power button
or pulling the plug cuts power in firmware. What limits the damage is that
pacman's commit phase is short (about a minute even for a 200-package upgrade)
and that most of a run is downloading, where an interruption costs nothing but
a partial file.
**The next run repairs it.** A `db.lck` left behind is detected and removed —
but only when it is *provably* dead, meaning it is older than the current boot,
so no process that could hold it still exists. The interrupted upgrade is then
simply run again; pacman reinstalls anything that was caught half-written. A
lock that is merely unheld within the same boot is never removed, only
reported, because there the guess could be wrong.
This last part matters more than it sounds: without it, a single power cut
during an update would leave a lock file that makes every future run defer,
and the machine would stop updating silently and permanently.
On a Btrfs system with `snapper` and `snap-pac` — the CachyOS default — every
pacman transaction is bracketed by a pre and post snapshot, so a genuinely
broken upgrade can still be rolled back with `snapper rollback`.
## Configuration
`/etc/cachy-auto-update/cachy-auto-update.conf`, one `Key=Value` per line, every
+37 -1
View File
@@ -16,7 +16,10 @@ updated in the background, with no password prompt and no terminal.
Run without a command it opens a small interactive menu with the two switches
that matter - automatic updates on/off and notifications on/off - plus the
current status.
current status, and a settings screen covering every remaining option so the
configuration file never has to be edited by hand. In the settings screen the
arrow keys select, Space or Right changes a value, and _q_ goes back; every
change is written out immediately.
The actual work is done by a systemd system service. The timer ticks hourly;
whether a tick does anything is decided by _UpdateInterval_ (daily by default).
@@ -100,6 +103,39 @@ running are skipped.
The machine is never restarted automatically. When a kernel update makes a
restart necessary, a notification says so.
# INTERRUPTED UPDATES
Before a transaction starts, a notification says an update is running and asks
for the machine to be left on. It is replaced in place by the result once the
run finishes.
While a transaction is running, *cachy-auto-update* holds a
*systemd-inhibit*(1) lock on _sleep_ and _shutdown_ in blocking mode, so a
suspend, a lid close or a normal shutdown request cannot cut it short.
What a user sees when they try anyway is worth knowing: logind refuses the
request and falls back to the polkit action
_org.freedesktop.login1.power-off-ignore-inhibit_, which is _auth_admin_keep_,
so the desktop presents an administrator password prompt reading "Power off the
system while an application is inhibiting this". That string is shipped
untranslated by systemd and does not mention updates, and no KDE dialog
explains the situation either - only *systemctl*(1) names the inhibitor and its
reason. Hence the notification.
A hard power-off - holding the power button, or losing mains power - is not
preventable. On the next run a leftover _/var/lib/pacman/db.lck_ is removed if
it is older than the current boot, since no process able to hold it can still
exist; the upgrade is then repeated and pacman reinstalls whatever was caught
half-written. A lock file that is unheld but was created during the current
boot is reported rather than removed, because there is no way to prove it is
abandoned.
Without that recovery a single power cut would leave a lock that makes every
subsequent run defer, silently stopping updates for good.
On Btrfs with *snapper*(8) and *snap-pac*, each pacman transaction is bracketed
by a pre and post snapshot, so a broken upgrade remains rollbackable.
# FILES
_/etc/cachy-auto-update/cachy-auto-update.conf_
+78
View File
@@ -257,3 +257,81 @@ msgstr ""
msgid "The last run was stopped before it finished."
msgstr ""
msgid "Finishing an interrupted update"
msgstr ""
msgid "The last update was cut short, most likely because the machine was switched off. It is being finished now."
msgstr ""
msgid "Installing updates"
msgstr ""
msgid "%d packages are being updated. Please leave the computer switched on until this is done."
msgstr ""
msgid "Settings"
msgstr ""
msgid "(none)"
msgstr ""
msgid "Up/Down select - Space or Right changes - q goes back"
msgstr ""
msgid "Package names separated by spaces, empty to clear:"
msgstr ""
msgid "Notify when an update starts"
msgstr ""
msgid "Notify after a successful update"
msgstr ""
msgid "Notify when something goes wrong"
msgstr ""
msgid "Notify when a restart is needed"
msgstr ""
msgid "Time between update runs"
msgstr ""
msgid "Postpone while a game is running"
msgstr ""
msgid "Only update on mains power"
msgstr ""
msgid "Minimum battery level (%)"
msgstr ""
msgid "Update AUR packages"
msgstr ""
msgid "Update Flatpaks"
msgstr ""
msgid "Update AppImages"
msgstr ""
msgid "Also rebuild -git packages"
msgstr ""
msgid "AUR helper"
msgstr ""
msgid "Resolve package conflicts automatically"
msgstr ""
msgid "Trim the package cache"
msgstr ""
msgid "Cached versions to keep"
msgstr ""
msgid "Remove packages nothing needs any more"
msgstr ""
msgid "Never update these packages"
msgstr ""
+79
View File
@@ -258,3 +258,82 @@ msgstr "Zurückgehalten: %s"
msgid "The last run was stopped before it finished."
msgstr "Der letzte Lauf wurde abgebrochen, bevor er fertig war."
msgid "Finishing an interrupted update"
msgstr "Abgebrochenes Update wird beendet"
msgid "The last update was cut short, most likely because the machine was switched off. It is being finished now."
msgstr "Das letzte Update wurde unterbrochen, vermutlich weil der Rechner ausgeschaltet wurde. Es wird jetzt zu Ende geführt."
msgid "Installing updates"
msgstr "Updates werden installiert"
#, c-format
msgid "%d packages are being updated. Please leave the computer switched on until this is done."
msgstr "%d Pakete werden gerade aktualisiert. Bitte den Rechner so lange eingeschaltet lassen."
msgid "Settings"
msgstr "Einstellungen"
msgid "(none)"
msgstr "(keine)"
msgid "Up/Down select - Space or Right changes - q goes back"
msgstr "Hoch/Runter wählen – Leertaste oder Rechts ändert – q zurück"
msgid "Package names separated by spaces, empty to clear:"
msgstr "Paketnamen mit Leerzeichen getrennt, leer zum Löschen:"
msgid "Notify when an update starts"
msgstr "Melden, wenn ein Update beginnt"
msgid "Notify after a successful update"
msgstr "Melden nach erfolgreichem Update"
msgid "Notify when something goes wrong"
msgstr "Melden, wenn etwas schiefgeht"
msgid "Notify when a restart is needed"
msgstr "Melden, wenn ein Neustart nötig ist"
msgid "Time between update runs"
msgstr "Abstand zwischen Update-Läufen"
msgid "Postpone while a game is running"
msgstr "Verschieben, solange ein Spiel läuft"
msgid "Only update on mains power"
msgstr "Nur am Stromnetz aktualisieren"
msgid "Minimum battery level (%)"
msgstr "Mindest-Akkustand (%)"
msgid "Update AUR packages"
msgstr "AUR-Pakete aktualisieren"
msgid "Update Flatpaks"
msgstr "Flatpaks aktualisieren"
msgid "Update AppImages"
msgstr "AppImages aktualisieren"
msgid "Also rebuild -git packages"
msgstr "Auch -git-Pakete neu bauen"
msgid "AUR helper"
msgstr "AUR-Helfer"
msgid "Resolve package conflicts automatically"
msgstr "Paketkonflikte automatisch auflösen"
msgid "Trim the package cache"
msgstr "Paket-Cache beschneiden"
msgid "Cached versions to keep"
msgstr "Behaltene Versionen im Cache"
msgid "Remove packages nothing needs any more"
msgstr "Nicht mehr benötigte Pakete entfernen"
msgid "Never update these packages"
msgstr "Diese Pakete nie aktualisieren"
+6
View File
@@ -95,6 +95,12 @@ RemoveOrphans=no
# Notification detail
# ---------------------------------------------------------------------------
# Say that an update has started. Worth keeping on: while one runs, a shutdown
# request is refused and the desktop answers with an untranslated polkit
# password prompt that never mentions updates. This message is replaced by the
# result when the run finishes, so it costs one bubble, not two.
NotifyOnStart=yes
# Say something after a successful update. Nothing is ever shown when there
# was nothing to do.
NotifyOnSuccess=yes
+11 -2
View File
@@ -117,6 +117,15 @@ if (( ! FORCE )); then
fi
fi
# A lock left behind by a power cut during a previous update. It is cleared
# before the busy check, because otherwise every future run would defer on it
# forever and the machine would quietly stop updating.
if cau_recover_stale_lock; then
cau_notify normal \
"Finishing an interrupted update" \
"The last update was cut short, most likely because the machine was switched off. It is being finished now."
fi
# This one is checked even with --force: proceeding anyway would just hand the
# user a lock error instead of doing anything useful.
CAU_SKIP_REASON=''
@@ -204,7 +213,7 @@ cau_state_write last_counts \
if (( failed )); then
cau_state_write last_result failed
cau_error "Update run finished with errors"
[[ $CFG_NOTIFY_ERROR == yes ]] && cau_notify critical \
[[ $CFG_NOTIFY_ERROR == yes ]] && cau_notify_tagged run yes critical \
"Update failed" \
"Something went wrong while updating. Run 'cachy-auto-update log' for details."
else
@@ -213,7 +222,7 @@ else
cau_info "Update run finished successfully ($total item(s) updated)"
if (( total > 0 )) && [[ $CFG_NOTIFY_SUCCESS == yes ]]; then
cau_notify low "System updated" "%d updates were installed." "$total"
cau_notify_tagged run yes low "System updated" "%d updates were installed." "$total"
fi
fi
+22 -1
View File
@@ -75,13 +75,34 @@ cau_msg() {
cau_msg_in "$(cau_ui_locale)" "$@"
}
# Translations are memoized. Every gettext lookup is a fork, and the settings
# 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
# switches back to single-character mode.
declare -A CAU_MSG_CACHE=()
# cau_msg_in <locale> <msgid> [printf args...]
cau_msg_in() {
local locale="$1" msgid="$2" translated
local locale="$1" msgid="$2" translated cachekey
shift 2
cachekey="${locale}"$'\x1f'"${msgid}"
if [[ -n ${CAU_MSG_CACHE[$cachekey]+set} ]]; then
translated="${CAU_MSG_CACHE[$cachekey]}"
else
translated="$(LC_ALL="$locale" LANGUAGE="${locale%%.*}" gettext -- "$msgid" 2>/dev/null)"
[[ -n $translated ]] || translated="$msgid"
CAU_MSG_CACHE[$cachekey]="$translated"
fi
# With no arguments the message is plain text, not a format string. Feeding
# it to printf anyway turns any literal percent sign in it - "Battery (%)",
# "100 % done" - into an invalid conversion, and that is a trap every
# translator would eventually walk into.
if (( $# == 0 )); then
printf '%s' "$translated"
return
fi
# shellcheck disable=SC2059 # the format string is the translated message
printf -- "$translated" "$@"
+24 -3
View File
@@ -6,14 +6,33 @@
# root-run daemon, and sourcing it would turn a stray line into arbitrary code
# execution. The format is one "Key=Value" per line, '#' starts a comment.
# The file is cached and parsed in-process rather than shelled out to sed on
# every lookup. The settings screen reads every key on every redraw, and a fork
# per key made the redraw slow enough to swallow keystrokes.
CAU_CONFIG_CACHE=''
CAU_CONFIG_CACHED=0
_cau_config_slurp() {
(( CAU_CONFIG_CACHED )) && return 0
CAU_CONFIG_CACHE=''
[[ -r $CAU_CONFIG ]] && CAU_CONFIG_CACHE="$(< "$CAU_CONFIG")"
CAU_CONFIG_CACHED=1
return 0
}
# cau_config_get <Key> [default]
cau_config_get() {
local key="$1" default="${2:-}" val
local key="$1" default="${2:-}" val='' line
[[ -r $CAU_CONFIG ]] || { printf '%s\n' "$default"; return; }
_cau_config_slurp
val="$(sed -nE "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*(.*)$/\\1/p" \
"$CAU_CONFIG" 2>/dev/null | tail -n1)"
# last assignment wins, matching the previous sed|tail behaviour
while IFS= read -r line; do
[[ $line == *"$key"* ]] || continue
[[ $line =~ ^[[:space:]]*"$key"[[:space:]]*=(.*)$ ]] || continue
val="${BASH_REMATCH[1]}"
done <<< "$CAU_CONFIG_CACHE"
# strip a trailing comment and surrounding whitespace/quotes
val="${val%%#*}"
@@ -78,6 +97,7 @@ cau_config_set() {
fi
mv -f "$tmp" "$CAU_CONFIG"
CAU_CONFIG_CACHED=0
}
# ---------------------------------------------------------------------------
@@ -98,6 +118,7 @@ cau_config_load() {
CFG_RESOLVE_CONFLICTS=no; cau_config_bool AutoResolveConflicts yes && CFG_RESOLVE_CONFLICTS=yes
CFG_CLEAN_CACHE=no; cau_config_bool CleanCache yes && CFG_CLEAN_CACHE=yes
CFG_REMOVE_ORPHANS=no; cau_config_bool RemoveOrphans no && CFG_REMOVE_ORPHANS=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_ERROR=no; cau_config_bool NotifyOnError yes && CFG_NOTIFY_ERROR=yes
CFG_NOTIFY_REBOOT=no; cau_config_bool NotifyReboot yes && CFG_NOTIFY_REBOOT=yes
+48 -4
View File
@@ -60,11 +60,55 @@ cau_package_manager_busy() {
return 1
}
# cau_pacman_lock_is_stale
# True only when the lock provably cannot belong to anything alive.
#
# The rigorous test is the boot time: no process that existed before the
# current boot can still be running, so a db.lck older than boot is abandoned
# by definition - which is exactly what a power cut during an update leaves
# behind. A lock that is merely unheld *within* this boot is not provable in
# the same way, so it is only reported (see cau_track_stale_lock) and never
# removed; guessing wrong there would corrupt a live transaction.
#
# The fuser check is kept as a second condition purely to survive a backwards
# clock jump making a live lock look pre-boot.
cau_pacman_lock_is_stale() {
local boot lock
[[ -e $CAU_PACMAN_LOCK ]] || return 1
boot="$(awk '/^btime /{print $2}' /proc/stat 2>/dev/null)"
[[ $boot =~ ^[0-9]+$ ]] || return 1
lock="$(stat -c %Y "$CAU_PACMAN_LOCK" 2>/dev/null)" || return 1
[[ $lock =~ ^[0-9]+$ ]] || return 1
(( lock < boot )) || return 1
[[ -z "$(cau_pacman_lock_holder)" ]]
}
# cau_recover_stale_lock
# Clears a provably abandoned lock so an interrupted update can be finished on
# the next run. Without this, one power cut during an update stops every future
# update permanently and silently - the worst possible outcome for a machine
# nobody is watching.
cau_recover_stale_lock() {
cau_pacman_lock_is_stale || return 1
cau_warn "Found a pacman lock older than this boot - an update was cut short"
rm -f "$CAU_PACMAN_LOCK" 2>/dev/null || {
cau_error "Could not remove the stale pacman lock"
return 1
}
cau_state_clear stale_lock_count
cau_info "Stale lock removed; the interrupted update will be finished now"
return 0
}
# cau_track_stale_lock
# A db.lck with no process behind it is left over from a crashed transaction.
# Removing it automatically would be reckless - if the guess is wrong it
# corrupts a live transaction - so instead it is counted, and after enough
# consecutive sightings the user is told to clean it up.
# A db.lck with no process behind it but created during this boot: a crashed
# pacman rather than a power cut. Not provable, so it is counted, and after
# enough consecutive sightings the user is told to clean it up.
CAU_STALE_LOCK_RUNS=3
cau_track_stale_lock() {
+302 -18
View File
@@ -7,6 +7,279 @@
CAU_UNIT="cachy-auto-update.timer"
# Terminal mode.
#
# bash flips the terminal into non-canonical mode for each `read -sn1` and back
# out again in between. That gap matters: in canonical mode DEL is the ERASE
# character, so the line discipline eats it instead of delivering it, and a
# backspace typed while the interface was between reads simply vanished.
# Holding non-canonical mode for the whole interface removes the gap.
CAU_TERM_SAVED=''
cau_ui_term_raw() {
cau_have stty || return 0
[[ -t 0 ]] || return 0
[[ -n $CAU_TERM_SAVED ]] && return 0
CAU_TERM_SAVED="$(stty -g 2>/dev/null)" || { CAU_TERM_SAVED=''; return 0; }
stty -icanon -echo min 1 time 0 2>/dev/null || true
}
cau_ui_term_restore() {
[[ -n $CAU_TERM_SAVED ]] || return 0
stty "$CAU_TERM_SAVED" 2>/dev/null || true
CAU_TERM_SAVED=''
}
# Runs an action with the terminal handed back to normal line mode, so anything
# it prints - or prompts for - behaves the way a program expects.
cau_ui_cooked() {
cau_ui_term_restore
"$@"
local rc=$?
cau_ui_term_raw
return $rc
}
# cau_read_key
# One keypress, resolved to a symbolic name: a literal character, or one of
# up/down/left/right/enter/space/escape. Arrow keys arrive as ESC [ A, so the
# tail of the sequence is consumed here rather than being mistaken for three
# separate presses.
cau_read_key() {
local k rest
IFS= read -rsn1 k || return 1
case "$k" in
$'\e')
if IFS= read -rsn2 -t 0.05 rest; then
case "$rest" in
'[A') printf 'up\n' ;;
'[B') printf 'down\n' ;;
'[C') printf 'right\n' ;;
'[D') printf 'left\n' ;;
*) printf 'escape\n' ;;
esac
else
printf 'escape\n'
fi
;;
# Enter is an empty read in cooked mode and a carriage return in raw
# mode, depending on whether the terminal is translating it.
''|$'\r') printf 'enter\n' ;;
$'\x7f'|$'\b') printf 'backspace\n' ;;
' ') printf 'space\n' ;;
*) printf '%s\n' "$k" ;;
esac
}
# cau_ui_read_line <initial>
# A minimal line editor built on cau_read_key, with the result in
# CAU_LINE_RESULT.
#
# This exists instead of bash's own `read -r` because mixing line mode into a
# single-key interface breaks it: after one cooked-mode read the following
# `read -sn1` stops receiving keystrokes entirely, reproducibly, on a real pty.
# Never leaving single-character mode side-steps that completely.
CAU_LINE_RESULT=''
cau_ui_read_line() {
local buf="${1:-}" key
CAU_LINE_RESULT=''
printf '%s' "$buf"
while true; do
key="$(cau_read_key)" || { printf '\n'; return 1; }
case "$key" in
enter)
printf '\n'
CAU_LINE_RESULT="$buf"
return 0
;;
escape)
printf '\n'
return 1
;;
backspace)
if [[ -n $buf ]]; then
buf="${buf%?}"
printf '\b \b'
fi
;;
space)
buf+=' '
printf ' '
;;
up|down|left|right) ;; # no cursor movement in this editor
*)
# a single printable character; control keys arrive as names
[[ ${#key} -eq 1 ]] || continue
buf+="$key"
printf '%s' "$key"
;;
esac
done
}
# Everything that can be changed without opening a text editor.
# Format: Key|type|default|label-msgid
# type is bool, choice:<space separated values>, or text.
CAU_SETTINGS=(
"NotifyOnStart|bool|yes|Notify when an update starts"
"NotifyOnSuccess|bool|yes|Notify after a successful update"
"NotifyOnError|bool|yes|Notify when something goes wrong"
"NotifyReboot|bool|yes|Notify when a restart is needed"
"UpdateInterval|choice:6h 12h 1d 2d 1w|1d|Time between update runs"
"SkipWhenGaming|bool|yes|Postpone while a game is running"
"RequireAC|bool|no|Only update on mains power"
"MinBatteryPercent|choice:0 20 30 40 50 60 70 80|40|Minimum battery level (%)"
"UpdateAUR|bool|yes|Update AUR packages"
"UpdateFlatpak|bool|yes|Update Flatpaks"
"UpdateAppImages|bool|yes|Update AppImages"
"UpdateDevel|bool|no|Also rebuild -git packages"
"AURHelper|choice:auto paru yay pikaur|auto|AUR helper"
"AutoResolveConflicts|bool|yes|Resolve package conflicts automatically"
"CleanCache|bool|yes|Trim the package cache"
"KeepOldPackages|choice:0 1 2 3 5|3|Cached versions to keep"
"RemoveOrphans|bool|no|Remove packages nothing needs any more"
"IgnorePkg|text||Never update these packages"
)
_cau_is_true() {
case "${1,,}" in
yes|y|true|1|on|enabled) return 0 ;;
*) return 1 ;;
esac
}
# _cau_setting_display <type> <value>
_cau_setting_display() {
local type="$1" value="$2"
case "$type" in
bool)
if _cau_is_true "$value"; then
printf '%s%s%s' "$CAU_C_GREEN" "$(cau_msg "ON")" "$CAU_C_RESET"
else
printf '%s%s%s' "$CAU_C_DIM" "$(cau_msg "OFF")" "$CAU_C_RESET"
fi
;;
text)
if [[ -n $value ]]; then
printf '%s' "$value"
else
printf '%s%s%s' "$CAU_C_DIM" "$(cau_msg "(none)")" "$CAU_C_RESET"
fi
;;
*) printf '%s' "$value" ;;
esac
}
# _cau_setting_cycle <type> <value> <direction: 1|-1>
# The next value for this setting. Choices wrap around, so one key is enough to
# reach everything without needing a second one for the other direction.
_cau_setting_cycle() {
local type="$1" value="$2" dir="$3"
if [[ $type == bool ]]; then
_cau_is_true "$value" && printf 'no\n' || printf 'yes\n'
return
fi
local -a choices
read -r -a choices <<< "${type#choice:}"
(( ${#choices[@]} )) || { printf '%s\n' "$value"; return; }
local i idx=0
for i in "${!choices[@]}"; do
[[ ${choices[i]} == "$value" ]] && { idx=$i; break; }
done
idx=$(( (idx + dir + ${#choices[@]}) % ${#choices[@]} ))
printf '%s\n' "${choices[idx]}"
}
# cau_ui_settings
# A cursor list rather than a numbered menu: there are eighteen settings, and
# numbering them would run out of digits and force paging.
cau_ui_settings() {
local cursor=0 key spec name type default label value line pad
local count=${#CAU_SETTINGS[@]}
while true; do
clear 2>/dev/null || true
cau_head " $(cau_msg "Settings")"
local i=0
for spec in "${CAU_SETTINGS[@]}"; do
IFS='|' read -r name type default label <<< "$spec"
value="$(cau_config_get "$name" "$default")"
if (( i == cursor )); then
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
printf '\n %s%s%s\n' "$CAU_C_DIM" \
"$(cau_msg "Up/Down select - Space or Right changes - q goes back")" "$CAU_C_RESET"
key="$(cau_read_key)" || return 0
IFS='|' read -r name type default label <<< "${CAU_SETTINGS[cursor]}"
value="$(cau_config_get "$name" "$default")"
case "$key" in
up|k) cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) cursor=$(( (cursor + 1) % count )) ;;
space|enter|right|l)
if [[ $type == text ]]; then
cau_ui_edit_text "$name" "$value"
else
cau_config_set "$name" "$(_cau_setting_cycle "$type" "$value" 1)" \
|| { cau_bad "$(cau_msg "Could not write the configuration file.")"; cau_pause; }
fi
;;
left|h)
[[ $type == text ]] || cau_config_set "$name" \
"$(_cau_setting_cycle "$type" "$value" -1)" \
|| { cau_bad "$(cau_msg "Could not write the configuration file.")"; cau_pause; }
;;
q|Q|escape) return 0 ;;
*) ;;
esac
done
}
# cau_ui_edit_text <key> <current>
# The one setting that is a free-text list rather than a choice.
cau_ui_edit_text() {
local name="$1" current="$2"
printf '\n %s\n' "$(cau_msg "Package names separated by spaces, empty to clear:")"
printf ' > '
# pre-filled with the current value so it can be corrected rather than
# retyped; Escape leaves it unchanged
if cau_ui_read_line "$current"; then
cau_config_set "$name" "$CAU_LINE_RESULT" \
|| { cau_bad "$(cau_msg "Could not write the configuration file.")"; cau_pause; }
fi
}
# _cau_row <label> <value>
# printf's %-28s pads by bytes, so a label containing "ü" comes out one column
# short. ${#s} counts characters in a UTF-8 locale, so the padding is computed
@@ -141,6 +414,10 @@ cau_ui_status_conditions() {
cau_ui_menu() {
local choice
cau_ui_term_raw
# restore the terminal even if this exits through Ctrl-C or an error
trap 'cau_ui_term_restore' EXIT INT TERM
while true; do
cau_config_load
@@ -153,45 +430,52 @@ cau_ui_menu() {
printf ' [3] %s\n' "$(cau_msg "Update now")"
printf ' [4] %s\n' "$(cau_msg "Show log")"
printf ' [5] %s\n' "$(cau_msg "Show current conditions")"
printf ' [6] %s\n' "$(cau_msg "Settings")"
printf ' [q] %s\n' "$(cau_msg "Quit")"
printf '\n > '
# One keypress, no Enter. -s keeps the raw character out of the
# display so the echo below is the only thing printed, and a failing
# read means EOF (Ctrl-D, or a script piping input) - that quits.
read -rsn1 choice || { printf '\n'; return 0; }
# One keypress, no Enter. A failing read means EOF (Ctrl-D, or a
# script piping input), which quits.
choice="$(cau_read_key)" || {
printf '\n'; cau_ui_term_restore; trap - EXIT INT TERM; return 0
}
case "$choice" in
enter|space|up|down|left|right|escape) choice='' ;;
esac
printf '%s\n' "$choice"
# Actions run in normal line mode: they print program output and some
# of them prompt, neither of which behaves in raw mode.
case "$choice" in
# The two switches flip and return straight to the menu, where the
# status block shows the result. Only a warning or an error holds
# the screen (see CAU_UI_NEEDS_ACK).
1)
CAU_UI_NEEDS_ACK=''
if [[ $CFG_ENABLED == yes ]]; then cau_do_disable; else cau_do_enable; fi
if [[ $CFG_ENABLED == yes ]]; then
cau_ui_cooked cau_do_disable
else
cau_ui_cooked cau_do_enable
fi
if [[ -n $CAU_UI_NEEDS_ACK ]]; then cau_pause; fi
;;
2)
CAU_UI_NEEDS_ACK=''
if [[ $CFG_NOTIFICATIONS == yes ]]; then
cau_do_notifications off
cau_ui_cooked cau_do_notifications off
else
cau_do_notifications on
cau_ui_cooked cau_do_notifications on
fi
if [[ -n $CAU_UI_NEEDS_ACK ]]; then cau_pause; fi
;;
3) cau_do_run --force; cau_pause ;;
4) cau_do_log; cau_pause ;;
3) cau_ui_cooked cau_do_run --force; cau_pause ;;
4) cau_ui_cooked cau_do_log; cau_pause ;;
5) cau_ui_status_conditions; cau_pause ;;
q|Q) return 0 ;;
$'\e')
# Arrow keys and friends arrive as ESC [ X. Swallow the rest so
# one keypress does not redraw the menu three times. Escape is
# deliberately not a quit key: that would make a stray arrow
# key close the menu.
read -rsn2 -t 0.05 _ 2>/dev/null || true
;;
# Anything else, Enter included, just redraws.
6) cau_ui_settings ;;
q|Q) cau_ui_term_restore; trap - EXIT INT TERM; return 0 ;;
# Anything else - Enter, arrow keys, stray characters - just
# redraws. Escape is deliberately not a quit key, so a mistyped
# arrow key cannot close the menu.
*) ;;
esac
done
+29 -5
View File
@@ -19,10 +19,23 @@ CAU_NOTIFY_QUEUE_MAX=20
# Never fails: a machine without libnotify, or with nobody logged in, is a
# normal state, not an error.
cau_notify() {
local urgency="$1" title="$2" body="$3"
shift 3
cau_notify_tagged '' yes "$@"
}
# cau_notify_tagged <tag> <queue: yes|no> <urgency> <title> <body> [args...]
#
# A tag makes this notification replace the previous one carrying the same tag
# rather than stacking a second bubble beside it - that is what turns
# "installing updates" into "system updated" in place instead of leaving two
# messages that contradict each other.
#
# queue=no is for messages that only mean anything while somebody is looking.
# Telling a user at next login that an update started an hour ago is noise.
cau_notify_tagged() {
local tag="$1" queue="$2" urgency="$3" title="$4" body="$5"
shift 5
local -a args=("$@")
local delivered=0 user uid locale t b
local delivered=0 user uid locale t b prev newid
[[ $CFG_NOTIFICATIONS == yes ]] || return 0
@@ -34,17 +47,28 @@ cau_notify() {
t="$(cau_msg_in "$locale" "$title")"
b="$(cau_msg_in "$locale" "$body" "${args[@]}")"
if cau_as_user "$user" "$uid" notify-send \
prev=0
if [[ -n $tag ]]; then
prev="$(cau_state_read "notify_id_${tag}_${user}" 0)"
[[ $prev =~ ^[0-9]+$ ]] || prev=0
fi
if newid="$(cau_as_user "$user" "$uid" notify-send \
--app-name="$CAU_PRETTY" \
--icon="$CAU_NOTIFY_ICON" \
--urgency="$urgency" \
-- "$t" "$b" 2>/dev/null
--print-id --replace-id="$prev" \
-- "$t" "$b" 2>/dev/null)"
then
delivered=1
if [[ -n $tag && $newid =~ ^[0-9]+$ ]]; then
cau_state_write "notify_id_${tag}_${user}" "$newid"
fi
fi
done < <(cau_active_session_users)
(( delivered )) && return 0
[[ $queue == yes ]] || return 0
cau_notify_enqueue "$urgency" "$title" "$body" "${args[@]}"
}
+14
View File
@@ -133,6 +133,20 @@ cau_pacman_update() {
cau_info "Running a full system upgrade (pending list unavailable)"
fi
# Say so before the transaction starts, not after it finishes.
#
# While an upgrade runs, a shutdown request is refused by logind and the
# desktop answers with a polkit password prompt reading "Power off the
# system while an application is inhibiting this" - which never mentions
# updates and, on a German system, is not even translated. Somebody who was
# simply told beforehand does not end up staring at that.
if [[ $CFG_NOTIFY_START == yes ]]; then
cau_notify_tagged run no normal \
"Installing updates" \
"%d packages are being updated. Please leave the computer switched on until this is done." \
"${CAU_PACMAN_COUNT:-0}"
fi
mapfile -t flags < <(cau_pacman_flags)
log="$(mktemp)" || return 1