Add cachy-auto-update: unattended background updates for CachyOS
A root systemd service applies pacman, AUR, Flatpak and AppImage updates on its own, gated on battery state, gaming activity and whether anybody else is using the package system. The CLI is deliberately two switches plus status. No user password is stored anywhere: pacman runs as root directly, and the AUR step - which makepkg forbids running as root - drops to a locked system account that sudoers permits to call pacman without a password.
This commit is contained in:
1 parent
327da1dae3
commit
ceb024d7be
28 files changed
+3856
-1
No files matched your search
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# cachy-auto-update - unattended updates for CachyOS
|
||||
#
|
||||
# The command line front end. Everything it does is either flipping a switch in
|
||||
# /etc/cachy-auto-update/cachy-auto-update.conf, driving the systemd timer, or
|
||||
# handing off to the runner in @LIBEXECDIR@.
|
||||
#
|
||||
# Copyright (C) 2026 Felitendo
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
CAU_LIBDIR="${CAU_LIBDIR:-@LIBDIR@}"
|
||||
CAU_LIBEXECDIR="${CAU_LIBEXECDIR:-@LIBEXECDIR@}"
|
||||
|
||||
for _mod in common config users conditions locks notify menu; do
|
||||
# shellcheck source=/dev/null
|
||||
if ! source "$CAU_LIBDIR/$_mod.sh"; then
|
||||
printf 'cachy-auto-update: cannot load %s/%s.sh\n' "$CAU_LIBDIR" "$_mod" >&2
|
||||
exit 14
|
||||
fi
|
||||
done
|
||||
unset _mod
|
||||
|
||||
CAU_SELF="$(readlink -f "${BASH_SOURCE[0]}")"
|
||||
CAU_ARGV=("$@")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Privilege
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Re-executes itself with elevation when a human is at the terminal, so that
|
||||
# typing `cachy-auto-update` just works instead of printing a lecture.
|
||||
cau_need_root() {
|
||||
cau_is_root && return 0
|
||||
|
||||
local candidate elevate=''
|
||||
if [[ -t 0 && -t 1 ]]; then
|
||||
for candidate in sudo run0 doas; do
|
||||
if cau_have "$candidate"; then elevate="$candidate"; break; fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n $elevate ]]; then
|
||||
exec "$elevate" "$CAU_SELF" "${CAU_ARGV[@]}"
|
||||
fi
|
||||
|
||||
cau_bad "$(cau_msg "This command has to run as root.")"
|
||||
exit 2
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
cau_do_enable() {
|
||||
cau_need_root
|
||||
|
||||
# A broken sudoers drop-in would take down sudo for the whole machine, so
|
||||
# it is verified before anything is switched on.
|
||||
if [[ -f /etc/sudoers.d/cachy-auto-update ]]; then
|
||||
if cau_have visudo && ! visudo -cf /etc/sudoers.d/cachy-auto-update > /dev/null 2>&1; then
|
||||
cau_bad "$(cau_msg "/etc/sudoers.d/cachy-auto-update is invalid; refusing to enable.")"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
cau_config_set Enabled yes || { cau_bad "$(cau_msg "Could not write the configuration file.")"; return 1; }
|
||||
|
||||
if cau_have systemctl; then
|
||||
systemctl enable --now cachy-auto-update.timer > /dev/null 2>&1 \
|
||||
|| cau_bad "$(cau_msg "Could not enable the systemd timer.")"
|
||||
fi
|
||||
|
||||
cau_ok "$(cau_msg "Automatic updates are on.")"
|
||||
|
||||
cau_config_load
|
||||
if [[ $CFG_AUR == yes ]] && ! cau_aur_detect_quiet; then
|
||||
cau_note "$(cau_msg "No AUR helper found - install paru or yay for AUR updates.")"
|
||||
fi
|
||||
if [[ $CFG_AUR == yes ]] && ! cau_have makepkg; then
|
||||
cau_note "$(cau_msg "base-devel is missing - AUR packages cannot be built without it.")"
|
||||
fi
|
||||
|
||||
cau_offer_disable_cachy_update
|
||||
}
|
||||
|
||||
# Whether an AUR helper exists at all, without the logging cau_aur_detect does.
|
||||
cau_aur_detect_quiet() {
|
||||
local c
|
||||
if [[ -n ${CFG_AUR_HELPER:-} && $CFG_AUR_HELPER != auto ]]; then
|
||||
cau_have "$CFG_AUR_HELPER"
|
||||
return $?
|
||||
fi
|
||||
for c in paru yay pikaur; do cau_have "$c" && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
# cachy-update ships an enabled user timer that only ever says "N updates
|
||||
# available". Once updates apply themselves that notification is pure noise,
|
||||
# so offer to silence it - but only ask, never decide.
|
||||
cau_offer_disable_cachy_update() {
|
||||
local user uid answer found=0
|
||||
|
||||
[[ -t 0 && -t 1 ]] || return 0
|
||||
cau_have systemctl || return 0
|
||||
|
||||
while read -r user uid; do
|
||||
[[ -n $user ]] || continue
|
||||
if cau_as_user "$user" "$uid" systemctl --user is-enabled --quiet arch-update.timer 2>/dev/null; then
|
||||
found=1
|
||||
break
|
||||
fi
|
||||
done < <(cau_active_session_users)
|
||||
|
||||
(( found )) || return 0
|
||||
|
||||
printf '\n %s\n ' \
|
||||
"$(cau_msg "cachy-update also notifies about available updates. Turn its notifications off? [y/N]")"
|
||||
read -r answer || return 0
|
||||
[[ ${answer,,} == y ]] || return 0
|
||||
|
||||
while read -r user uid; do
|
||||
[[ -n $user ]] || continue
|
||||
cau_as_user "$user" "$uid" systemctl --user disable --now arch-update.timer > /dev/null 2>&1 || true
|
||||
done < <(cau_active_session_users)
|
||||
|
||||
cau_ok "$(cau_msg "cachy-update's update check has been disabled.")"
|
||||
}
|
||||
|
||||
cau_do_disable() {
|
||||
cau_need_root
|
||||
|
||||
cau_config_set Enabled no || { cau_bad "$(cau_msg "Could not write the configuration file.")"; return 1; }
|
||||
|
||||
if cau_have systemctl; then
|
||||
systemctl disable --now cachy-auto-update.timer > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
cau_ok "$(cau_msg "Automatic updates are off.")"
|
||||
}
|
||||
|
||||
cau_do_notifications() {
|
||||
cau_need_root
|
||||
|
||||
case "${1:-}" in
|
||||
on|yes|true|1)
|
||||
cau_config_set Notifications yes || return 1
|
||||
cau_ok "$(cau_msg "Notifications are on.")"
|
||||
;;
|
||||
off|no|false|0)
|
||||
cau_config_set Notifications no || return 1
|
||||
cau_ok "$(cau_msg "Notifications are off.")"
|
||||
;;
|
||||
*)
|
||||
cau_bad "$(cau_msg "Usage: cachy-auto-update notifications on|off")"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
cau_do_status() {
|
||||
cau_config_load
|
||||
cau_head " $CAU_PRETTY"
|
||||
cau_ui_status
|
||||
cau_ui_status_conditions
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
cau_do_run() {
|
||||
cau_need_root
|
||||
exec "$CAU_LIBEXECDIR/cachy-auto-update-run" "$@"
|
||||
}
|
||||
|
||||
cau_do_log() {
|
||||
local file="$CAU_RUNLOG" lines=200 follow=0
|
||||
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
-n) lines="${2:-200}"; shift 2 ;;
|
||||
-n*) lines="${1#-n}"; shift ;;
|
||||
-f|--follow) follow=1; shift ;;
|
||||
-a|--all) file="$CAU_LOGFILE"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -r $file ]]; then
|
||||
cau_note "$(cau_msg "No log yet.")"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if (( follow )); then
|
||||
tail -n "$lines" -f "$file"
|
||||
else
|
||||
tail -n "$lines" "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
cau_do_help() {
|
||||
cat <<- EOF
|
||||
$CAU_PRETTY $CAU_VERSION
|
||||
|
||||
$(cau_msg "Usage: cachy-auto-update [command]")
|
||||
|
||||
$(cau_msg "Commands:")
|
||||
enable $(cau_msg "Turn automatic updates on")
|
||||
disable $(cau_msg "Turn automatic updates off")
|
||||
notifications on|off $(cau_msg "Turn desktop notifications on or off")
|
||||
status $(cau_msg "Show the current state and conditions")
|
||||
run [--force] [--dry-run] $(cau_msg "Run an update now")
|
||||
log [-n N] [-f] [-a] $(cau_msg "Show the log of the last run")
|
||||
-h, --help $(cau_msg "Show this help")
|
||||
-V, --version $(cau_msg "Show the version")
|
||||
|
||||
$(cau_msg "Without a command an interactive menu is shown.")
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
main() {
|
||||
local cmd="${1:-}"
|
||||
[[ $# -gt 0 ]] && shift
|
||||
|
||||
case "$cmd" in
|
||||
enable) cau_do_enable "$@" ;;
|
||||
disable) cau_do_disable "$@" ;;
|
||||
notifications) cau_do_notifications "$@" ;;
|
||||
status) cau_do_status "$@" ;;
|
||||
run) cau_do_run "$@" ;;
|
||||
log) cau_do_log "$@" ;;
|
||||
|
||||
# Invoked unprivileged from the XDG autostart entry to replay
|
||||
# notifications that were produced while nobody was logged in.
|
||||
--deliver-notifications)
|
||||
cau_config_load
|
||||
cau_notify_deliver_queue
|
||||
;;
|
||||
|
||||
-h|--help|help) cau_do_help ;;
|
||||
-V|--version) printf '%s %s\n' "$CAU_NAME" "$CAU_VERSION" ;;
|
||||
|
||||
'')
|
||||
cau_need_root
|
||||
cau_ui_menu
|
||||
;;
|
||||
*)
|
||||
cau_bad "$(cau_msg "Unknown command: %s" "$cmd")"
|
||||
printf '\n'
|
||||
cau_do_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# cachy-auto-update-run - one unattended update pass
|
||||
#
|
||||
# Started by cachy-auto-update.service as root. The timer fires hourly and this
|
||||
# decides whether anything should happen; that is what makes deferrals free.
|
||||
# Refusing to run is the normal, expected outcome most of the time, so it is
|
||||
# never treated as an error.
|
||||
#
|
||||
# Copyright (C) 2026 Felitendo
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
CAU_LIBDIR="${CAU_LIBDIR:-@LIBDIR@}"
|
||||
|
||||
for _mod in common config users conditions locks notify \
|
||||
pkg_pacman pkg_aur pkg_flatpak pkg_appimage; do
|
||||
# shellcheck source=/dev/null
|
||||
if ! source "$CAU_LIBDIR/$_mod.sh"; then
|
||||
printf 'cachy-auto-update-run: cannot load %s/%s.sh\n' "$CAU_LIBDIR" "$_mod" >&2
|
||||
exit 14
|
||||
fi
|
||||
done
|
||||
unset _mod
|
||||
|
||||
FORCE=0
|
||||
DRY_RUN=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--force) FORCE=1 ;;
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--debug) CAU_DEBUG=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
printf 'cachy-auto-update-run: must run as root\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Re-exec under an inhibitor so a suspend or shutdown cannot land in the middle
|
||||
# of a pacman transaction and leave the database half-written. Done before
|
||||
# anything else so the whole pass is covered, including the lock.
|
||||
if [[ -z ${CAU_INHIBITED:-} ]] && command -v systemd-inhibit > /dev/null 2>&1; then
|
||||
export CAU_INHIBITED=1
|
||||
exec systemd-inhibit \
|
||||
--what=sleep:shutdown \
|
||||
--mode=block \
|
||||
--who="CachyOS Auto-Update" \
|
||||
--why="Applying system updates" \
|
||||
-- "$0" "$@"
|
||||
fi
|
||||
|
||||
cau_config_load
|
||||
cau_log_open
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Should this run happen at all?
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defer() {
|
||||
cau_info "Deferred: $1"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ! cau_acquire_lock; then
|
||||
cau_debug "Another run is already in progress"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if (( ! FORCE )); then
|
||||
if [[ $CFG_ENABLED != yes ]]; then
|
||||
cau_debug "Automatic updates are disabled"
|
||||
exit 0
|
||||
fi
|
||||
if ! cau_update_due; then
|
||||
cau_debug "Not due yet (interval $CFG_INTERVAL)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
cau_state_write last_run "$(date +%s)"
|
||||
|
||||
if (( ! FORCE )); then
|
||||
CAU_SKIP_REASON=''
|
||||
cau_power_ok || defer "$CAU_SKIP_REASON"
|
||||
|
||||
if [[ $CFG_SKIP_GAMING == yes ]]; then
|
||||
CAU_SKIP_REASON=''
|
||||
if cau_busy; then
|
||||
defer "$CAU_SKIP_REASON"
|
||||
fi
|
||||
fi
|
||||
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=''
|
||||
if cau_package_manager_busy; then
|
||||
if cau_track_stale_lock; then
|
||||
cau_warn "pacman's database lock appears to be stale"
|
||||
cau_notify normal \
|
||||
"Package database locked" \
|
||||
"pacman's lock file looks left over from an interrupted update. Updates are paused until it is cleared."
|
||||
fi
|
||||
defer "$CAU_SKIP_REASON"
|
||||
fi
|
||||
cau_state_clear stale_lock_count
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dry run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if (( DRY_RUN )); then
|
||||
printf '%s\n' "Would update on this machine:"
|
||||
|
||||
if cau_pacman_pending; then
|
||||
printf ' repository packages: %s\n' "$(grep -c . <<< "$CAU_PACMAN_PENDING")"
|
||||
sed 's/^/ /' <<< "$CAU_PACMAN_PENDING"
|
||||
else
|
||||
printf ' repository packages: 0\n'
|
||||
fi
|
||||
|
||||
if [[ $CFG_AUR == yes ]] && cau_aur_ready; then
|
||||
printf ' AUR packages: %s (%s)\n' "$(cau_aur_pending)" "$CAU_AUR_HELPER"
|
||||
else
|
||||
printf ' AUR packages: skipped\n'
|
||||
fi
|
||||
|
||||
if [[ $CFG_FLATPAK == yes ]] && cau_have flatpak; then
|
||||
printf ' system Flatpaks: %s\n' "$(cau_flatpak_pending_system)"
|
||||
else
|
||||
printf ' system Flatpaks: skipped\n'
|
||||
fi
|
||||
|
||||
printf ' AppImages: %s\n' \
|
||||
"$([[ $CFG_APPIMAGE == yes ]] && printf 'checked via Gear Lever' || printf 'skipped')"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Do the work
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
cau_info "Starting update run"
|
||||
failed=0
|
||||
|
||||
if ! cau_pacman_update; then
|
||||
failed=1
|
||||
fi
|
||||
|
||||
if [[ $CFG_AUR == yes ]]; then
|
||||
cau_aur_update || failed=1
|
||||
fi
|
||||
|
||||
if [[ $CFG_FLATPAK == yes ]]; then
|
||||
cau_flatpak_update || failed=1
|
||||
fi
|
||||
|
||||
if [[ $CFG_APPIMAGE == yes ]]; then
|
||||
cau_appimage_update || true # best effort; never marks the run as failed
|
||||
fi
|
||||
|
||||
cau_pacman_cleanup
|
||||
|
||||
pacnew="$(cau_pacman_pacnew_count)"
|
||||
if [[ $pacnew =~ ^[0-9]+$ ]] && (( pacnew > 0 )); then
|
||||
cau_info "$pacnew .pacnew file(s) present; left untouched"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Record and report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
total=$(( CAU_PACMAN_COUNT + CAU_AUR_COUNT + CAU_FLATPAK_COUNT + CAU_APPIMAGE_COUNT ))
|
||||
|
||||
cau_state_write last_counts \
|
||||
"$CAU_PACMAN_COUNT $CAU_AUR_COUNT $CAU_FLATPAK_COUNT $CAU_APPIMAGE_COUNT"
|
||||
|
||||
if (( failed )); then
|
||||
cau_state_write last_result failed
|
||||
cau_error "Update run finished with errors"
|
||||
[[ $CFG_NOTIFY_ERROR == yes ]] && cau_notify critical \
|
||||
"Update failed" \
|
||||
"Something went wrong while updating. Run 'cachy-auto-update log' for details."
|
||||
else
|
||||
cau_state_write last_result ok
|
||||
cau_state_write last_success "$(date +%s)"
|
||||
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"
|
||||
fi
|
||||
fi
|
||||
|
||||
if cau_pacman_reboot_needed; then
|
||||
cau_state_write reboot_needed 1
|
||||
cau_info "A kernel update needs a restart"
|
||||
[[ $CFG_NOTIFY_REBOOT == yes ]] && cau_notify normal \
|
||||
"Restart recommended" \
|
||||
"A new kernel was installed. Please restart when it suits you."
|
||||
else
|
||||
cau_state_write reboot_needed 0
|
||||
fi
|
||||
|
||||
# Keep the state readable for the unprivileged `status` and `log` commands.
|
||||
# Only the plain files: the build account's home lives in here too and must
|
||||
# keep its own ownership and mode.
|
||||
chmod 0755 "$CAU_STATEDIR" 2>/dev/null || true
|
||||
find "$CAU_STATEDIR" -maxdepth 1 -type f -exec chmod 0644 {} + 2>/dev/null || true
|
||||
|
||||
exit $(( failed ? 1 : 0 ))
|
||||
@@ -0,0 +1,225 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Paths, logging, translations and small shared helpers.
|
||||
#
|
||||
# Sourced by both the CLI and the update runner, so it must not assume it is
|
||||
# running as root and must not produce any output on its own.
|
||||
|
||||
CAU_VERSION="@VERSION@"
|
||||
CAU_NAME="cachy-auto-update"
|
||||
CAU_PRETTY="CachyOS Auto-Update"
|
||||
|
||||
# Directories. All of them are overridable so the tree can be exercised
|
||||
# straight from a git checkout without installing anything.
|
||||
CAU_LIBDIR="${CAU_LIBDIR:-@LIBDIR@}"
|
||||
CAU_LIBEXECDIR="${CAU_LIBEXECDIR:-@LIBEXECDIR@}"
|
||||
CAU_LOCALEDIR="${CAU_LOCALEDIR:-@LOCALEDIR@}"
|
||||
CAU_CONFDIR="${CAU_CONFDIR:-/etc/cachy-auto-update}"
|
||||
CAU_CONFIG="${CAU_CONFIG:-${CAU_CONFDIR}/cachy-auto-update.conf}"
|
||||
CAU_STATEDIR="${CAU_STATEDIR:-/var/lib/cachy-auto-update}"
|
||||
CAU_CACHEDIR="${CAU_CACHEDIR:-/var/cache/cachy-auto-update}"
|
||||
CAU_LOGDIR="${CAU_LOGDIR:-/var/log/cachy-auto-update}"
|
||||
CAU_RUNDIR="${CAU_RUNDIR:-/run/cachy-auto-update}"
|
||||
|
||||
CAU_LOGFILE="${CAU_LOGDIR}/cachy-auto-update.log"
|
||||
CAU_RUNLOG="${CAU_LOGDIR}/last-run.log"
|
||||
CAU_LOCKFILE="${CAU_RUNDIR}/run.lock"
|
||||
CAU_NOTIFY_QUEUE="${CAU_STATEDIR}/notify-queue"
|
||||
|
||||
# The locked system account that builds and installs AUR packages. Created by
|
||||
# sysusers.d; its home lives under the state directory.
|
||||
CAU_BUILD_USER="cachy-auto-update"
|
||||
CAU_BUILD_HOME="${CAU_STATEDIR}/builder"
|
||||
|
||||
# Pacman's own database lock. Presence means somebody is mid-transaction.
|
||||
CAU_PACMAN_LOCK="/var/lib/pacman/db.lck"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Translations
|
||||
# ---------------------------------------------------------------------------
|
||||
# Messages are authored in English and translated through gettext. The runner
|
||||
# is started with LC_ALL=C so that pacman/upower output stays parseable, which
|
||||
# means every user-facing string has to opt back into a real locale explicitly
|
||||
# via cau_msg / cau_msg_in.
|
||||
|
||||
export TEXTDOMAIN="cachy-auto-update"
|
||||
export TEXTDOMAINDIR="${CAU_LOCALEDIR}"
|
||||
|
||||
# The locale user-facing text should be rendered in.
|
||||
#
|
||||
# Standard POSIX precedence, deliberately: LC_ALL wins outright, and LC_ALL=C
|
||||
# really does mean English. The service sets LC_ALL=C so that pacman and upower
|
||||
# stay parseable, which makes the log English - correct, since the log is a
|
||||
# technical artefact. Anything aimed at a person (a desktop notification) does
|
||||
# not go through here at all; it names the recipient's own locale explicitly
|
||||
# via cau_msg_in and cau_user_locale.
|
||||
cau_ui_locale() {
|
||||
local l="${CAU_UI_LOCALE:-}"
|
||||
|
||||
if [[ -z $l ]]; then
|
||||
l="${LC_ALL:-}"
|
||||
[[ -z $l ]] && l="${LC_MESSAGES:-}"
|
||||
[[ -z $l ]] && l="${LANG:-}"
|
||||
fi
|
||||
|
||||
if [[ -z $l && -r /etc/locale.conf ]]; then
|
||||
l="$(sed -n 's/^LANG=//p' /etc/locale.conf | tr -d '"' | head -n1)"
|
||||
fi
|
||||
|
||||
printf '%s\n' "${l:-C}"
|
||||
}
|
||||
|
||||
# cau_msg <msgid> [printf args...]
|
||||
# Translate a string into the UI locale and printf it (no trailing newline).
|
||||
cau_msg() {
|
||||
cau_msg_in "$(cau_ui_locale)" "$@"
|
||||
}
|
||||
|
||||
# cau_msg_in <locale> <msgid> [printf args...]
|
||||
cau_msg_in() {
|
||||
local locale="$1" msgid="$2" translated
|
||||
shift 2
|
||||
|
||||
translated="$(LC_ALL="$locale" LANGUAGE="${locale%%.*}" gettext -- "$msgid" 2>/dev/null)"
|
||||
[[ -n $translated ]] || translated="$msgid"
|
||||
|
||||
# shellcheck disable=SC2059 # the format string is the translated message
|
||||
printf -- "$translated" "$@"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output and logging
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [[ -t 1 && -z ${NO_COLOR:-} ]]; then
|
||||
CAU_C_RESET=$'\033[0m'
|
||||
CAU_C_BOLD=$'\033[1m'
|
||||
CAU_C_DIM=$'\033[2m'
|
||||
CAU_C_BLUE=$'\033[38;2;23;147;209m'
|
||||
CAU_C_GREEN=$'\033[32m'
|
||||
CAU_C_YELLOW=$'\033[33m'
|
||||
CAU_C_RED=$'\033[31m'
|
||||
else
|
||||
CAU_C_RESET='' CAU_C_BOLD='' CAU_C_DIM='' CAU_C_BLUE=''
|
||||
CAU_C_GREEN='' CAU_C_YELLOW='' CAU_C_RED=''
|
||||
fi
|
||||
|
||||
# Appends to the persistent log when writable (i.e. when running as root) and
|
||||
# always echoes to stderr so journald picks it up for the service.
|
||||
cau_log() {
|
||||
local level="$1"
|
||||
shift
|
||||
local line
|
||||
line="$(date '+%Y-%m-%d %H:%M:%S') [$level] $*"
|
||||
|
||||
printf '%s\n' "$line" >&2
|
||||
if [[ -n ${CAU_LOG_OPEN:-} ]]; then
|
||||
printf '%s\n' "$line" >> "$CAU_LOGFILE" 2>/dev/null || true
|
||||
printf '%s\n' "$line" >> "$CAU_RUNLOG" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
cau_info() { cau_log INFO "$@"; }
|
||||
cau_warn() { cau_log WARN "$@"; }
|
||||
cau_error() { cau_log ERROR "$@"; }
|
||||
cau_debug() { [[ -n ${CAU_DEBUG:-} ]] && cau_log DEBUG "$@"; return 0; }
|
||||
|
||||
# Opens the log files for this run. Only meaningful as root.
|
||||
cau_log_open() {
|
||||
mkdir -p "$CAU_LOGDIR" 2>/dev/null || return 0
|
||||
chmod 0750 "$CAU_LOGDIR" 2>/dev/null || true
|
||||
: > "$CAU_RUNLOG" 2>/dev/null || return 0
|
||||
CAU_LOG_OPEN=1
|
||||
}
|
||||
|
||||
# Runs a command, streaming its combined output into the run log. Returns the
|
||||
# command's exit status.
|
||||
cau_run_logged() {
|
||||
if [[ -n ${CAU_LOG_OPEN:-} ]]; then
|
||||
"$@" >> "$CAU_RUNLOG" 2>&1
|
||||
else
|
||||
"$@" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Terminal helpers for the CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
cau_say() { printf '%s\n' "$*"; }
|
||||
cau_head() { printf '\n%s%s%s\n\n' "$CAU_C_BOLD$CAU_C_BLUE" "$*" "$CAU_C_RESET"; }
|
||||
cau_ok() { printf '%s✔%s %s\n' "$CAU_C_GREEN" "$CAU_C_RESET" "$*"; }
|
||||
cau_bad() { printf '%s✘%s %s\n' "$CAU_C_RED" "$CAU_C_RESET" "$*" >&2; }
|
||||
cau_note() { printf '%s•%s %s\n' "$CAU_C_DIM" "$CAU_C_RESET" "$*"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State files
|
||||
# ---------------------------------------------------------------------------
|
||||
# Every piece of state is one small file under CAU_STATEDIR. That keeps the
|
||||
# CLI, the runner and the login-time notification delivery from needing any
|
||||
# shared parsing code.
|
||||
|
||||
cau_state_read() {
|
||||
local key="$1" default="${2:-}"
|
||||
if [[ -r "$CAU_STATEDIR/$key" ]]; then
|
||||
cat "$CAU_STATEDIR/$key"
|
||||
else
|
||||
printf '%s\n' "$default"
|
||||
fi
|
||||
}
|
||||
|
||||
cau_state_write() {
|
||||
local key="$1"
|
||||
shift
|
||||
mkdir -p "$CAU_STATEDIR" 2>/dev/null || return 1
|
||||
printf '%s\n' "$*" > "$CAU_STATEDIR/$key"
|
||||
}
|
||||
|
||||
cau_state_clear() {
|
||||
rm -f "$CAU_STATEDIR/$1" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Misc
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
cau_have() { command -v "$1" > /dev/null 2>&1; }
|
||||
|
||||
cau_is_root() { [[ $EUID -eq 0 ]]; }
|
||||
|
||||
# Turns a duration such as "1d", "6h", "30m" or a bare number of seconds into
|
||||
# seconds. Returns the fallback for anything unparseable.
|
||||
cau_duration_to_seconds() {
|
||||
local v="$1" fallback="${2:-86400}" num unit
|
||||
|
||||
[[ $v =~ ^([0-9]+)([smhdw]?)$ ]] || { printf '%s\n' "$fallback"; return; }
|
||||
num="${BASH_REMATCH[1]}"
|
||||
unit="${BASH_REMATCH[2]}"
|
||||
|
||||
case "$unit" in
|
||||
s|'') printf '%s\n' "$num" ;;
|
||||
m) printf '%s\n' "$(( num * 60 ))" ;;
|
||||
h) printf '%s\n' "$(( num * 3600 ))" ;;
|
||||
d) printf '%s\n' "$(( num * 86400 ))" ;;
|
||||
w) printf '%s\n' "$(( num * 604800 ))" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Human-readable "x minutes ago" for a unix timestamp. Empty input yields the
|
||||
# translated "never".
|
||||
cau_time_ago() {
|
||||
local ts="$1" now delta
|
||||
|
||||
[[ $ts =~ ^[0-9]+$ ]] || { cau_msg "never"; printf '\n'; return; }
|
||||
|
||||
now="$(date +%s)"
|
||||
delta=$(( now - ts ))
|
||||
(( delta < 0 )) && delta=0
|
||||
|
||||
if (( delta < 60 )); then cau_msg "just now"
|
||||
elif (( delta < 3600 )); then cau_msg "%d minutes ago" "$(( delta / 60 ))"
|
||||
elif (( delta < 86400 )); then cau_msg "%d hours ago" "$(( delta / 3600 ))"
|
||||
else cau_msg "%d days ago" "$(( delta / 86400 ))"
|
||||
fi
|
||||
printf '\n'
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Should we update right now?
|
||||
#
|
||||
# Every check here is allowed to say "not now" and nothing more. The timer
|
||||
# ticks hourly, so a deferral costs nothing: the next tick simply tries again.
|
||||
# That is also why none of these functions ever return a hard failure.
|
||||
|
||||
# Set by the checks below to a human-readable, already translated reason.
|
||||
CAU_SKIP_REASON=''
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Power
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# cau_on_ac
|
||||
# True when running on mains power. systemd-ac-power also returns success when
|
||||
# the machine has neither a battery nor an adapter, which is exactly right for
|
||||
# desktops - hand-rolled sysfs globbing gets that case wrong.
|
||||
cau_on_ac() {
|
||||
if cau_have systemd-ac-power; then
|
||||
systemd-ac-power > /dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
|
||||
local ps online
|
||||
for ps in /sys/class/power_supply/*; do
|
||||
[[ -r "$ps/type" ]] || continue
|
||||
[[ "$(< "$ps/type")" == Mains ]] || continue
|
||||
[[ -r "$ps/online" ]] || continue
|
||||
online="$(< "$ps/online")"
|
||||
[[ $online == 1 ]] && return 0
|
||||
done
|
||||
|
||||
# No mains device found at all: if there is no system battery either this
|
||||
# is a desktop, so assume mains rather than blocking updates forever.
|
||||
cau_battery_percent > /dev/null || return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# cau_battery_percent
|
||||
# Average charge across the system batteries, or failure when the machine has
|
||||
# none. Peripheral batteries (mice, headsets) advertise type=Battery too and
|
||||
# are filtered out via the scope attribute; when scope is missing entirely -
|
||||
# as on many laptops - the device counts as a system battery.
|
||||
cau_battery_percent() {
|
||||
local ps sum=0 count=0 cap
|
||||
|
||||
for ps in /sys/class/power_supply/*; do
|
||||
[[ -r "$ps/type" ]] || continue
|
||||
[[ "$(< "$ps/type")" == Battery ]] || continue
|
||||
|
||||
if [[ -r "$ps/scope" ]]; then
|
||||
[[ "$(< "$ps/scope")" == System ]] || continue
|
||||
fi
|
||||
if [[ -r "$ps/present" ]]; then
|
||||
[[ "$(< "$ps/present")" == 1 ]] || continue
|
||||
fi
|
||||
[[ -r "$ps/capacity" ]] || continue
|
||||
|
||||
cap="$(< "$ps/capacity")"
|
||||
[[ $cap =~ ^[0-9]+$ ]] || continue
|
||||
sum=$(( sum + cap ))
|
||||
count=$(( count + 1 ))
|
||||
done
|
||||
|
||||
(( count > 0 )) || return 1
|
||||
printf '%s\n' "$(( sum / count ))"
|
||||
}
|
||||
|
||||
# cau_power_ok
|
||||
# Applies RequireAC and MinBatteryPercent.
|
||||
cau_power_ok() {
|
||||
local pct
|
||||
|
||||
if cau_on_ac; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ $CFG_REQUIRE_AC == yes ]]; then
|
||||
CAU_SKIP_REASON="$(cau_msg "running on battery")"
|
||||
return 1
|
||||
fi
|
||||
|
||||
pct="$(cau_battery_percent)" || return 0 # no battery: nothing to gate on
|
||||
|
||||
if (( pct < CFG_MIN_BATTERY )); then
|
||||
CAU_SKIP_REASON="$(cau_msg "battery at %d%%, below the %d%% threshold" \
|
||||
"$pct" "$CFG_MIN_BATTERY")"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Is the user in the middle of something?
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Process names that mean a game is actually running. Deliberately excludes
|
||||
# "steam" itself, which sits in the tray all day on a gaming machine.
|
||||
CAU_GAME_PROCESSES=(
|
||||
gamescope
|
||||
wine wine64 wineserver wine-preloader wine64-preloader
|
||||
umu-run proton
|
||||
reaper
|
||||
lutris lutris-wrapper heroic bottles
|
||||
retroarch dolphin-emu pcsx2-qt rpcs3 cemu ryujinx ppsspp citra-qt duckstation-qt
|
||||
)
|
||||
|
||||
# cau_process_running <name>
|
||||
# Exact-name match that ignores kernel threads. The exactness matters: a
|
||||
# substring match on "reaper" hits the kernel's own oom_reaper on every box.
|
||||
cau_process_running() {
|
||||
local name="$1" pid ppid
|
||||
|
||||
while read -r pid; do
|
||||
[[ $pid =~ ^[0-9]+$ ]] || continue
|
||||
(( pid == 2 )) && continue
|
||||
# /proc/<pid>/status rather than /stat: the comm field in /stat can
|
||||
# contain spaces and parentheses, which shifts every column after it.
|
||||
ppid="$(sed -n 's/^PPid:[[:space:]]*//p' "/proc/$pid/status" 2>/dev/null)" || continue
|
||||
[[ $ppid == 2 ]] && continue # kernel thread
|
||||
return 0
|
||||
done < <(pgrep -x -- "$name" 2>/dev/null)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# cau_gamemode_active
|
||||
# Asks GameMode how many clients it is tracking, per graphical session.
|
||||
# GameMode is optional and frequently absent; any failure means "unknown", not
|
||||
# "busy".
|
||||
cau_gamemode_active() {
|
||||
local user uid out count
|
||||
|
||||
cau_have busctl || return 1
|
||||
|
||||
while read -r user uid; do
|
||||
[[ -n $user ]] || continue
|
||||
# timeout runs inside the runuser call because it has to be a real
|
||||
# binary there - it cannot wrap a shell function from out here.
|
||||
out="$(cau_as_user "$user" "$uid" timeout 5 busctl --user --json=short \
|
||||
get-property com.feralinteractive.GameMode \
|
||||
/com/feralinteractive/GameMode \
|
||||
com.feralinteractive.GameMode ClientCount 2>/dev/null)" || continue
|
||||
count="$(sed -nE 's/.*"data"[[:space:]]*:[[:space:]]*([0-9]+).*/\1/p' <<< "$out")"
|
||||
[[ -z $count ]] && count="$(awk '{print $NF}' <<< "$out")"
|
||||
[[ $count =~ ^[0-9]+$ ]] || continue
|
||||
(( count > 0 )) && return 0
|
||||
done < <(cau_active_session_users)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# cau_idle_inhibited
|
||||
# A blocking "idle" inhibitor is what fullscreen games and video players take.
|
||||
cau_idle_inhibited() {
|
||||
cau_have systemd-inhibit || return 1
|
||||
systemd-inhibit --list 2>/dev/null \
|
||||
| awk 'tolower($0) ~ /idle/ && $NF == "block" { found = 1 } END { exit !found }'
|
||||
}
|
||||
|
||||
# cau_busy
|
||||
# True when something is running that an update should not interrupt.
|
||||
cau_busy() {
|
||||
local proc
|
||||
|
||||
if cau_gamemode_active; then
|
||||
CAU_SKIP_REASON="$(cau_msg "a game is running (GameMode)")"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for proc in "${CAU_GAME_PROCESSES[@]}"; do
|
||||
if cau_process_running "$proc"; then
|
||||
CAU_SKIP_REASON="$(cau_msg "a game is running (%s)" "$proc")"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
if cau_idle_inhibited; then
|
||||
CAU_SKIP_REASON="$(cau_msg "an application is blocking idle (fullscreen game or video)")"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# cau_update_due
|
||||
# The timer fires hourly; this decides whether enough time has passed since the
|
||||
# last *successful* run. Deferred runs therefore retry automatically without
|
||||
# any backoff bookkeeping of their own.
|
||||
cau_update_due() {
|
||||
local last now
|
||||
|
||||
last="$(cau_state_read last_success 0)"
|
||||
[[ $last =~ ^[0-9]+$ ]] || last=0
|
||||
(( last == 0 )) && return 0
|
||||
|
||||
now="$(date +%s)"
|
||||
(( now - last >= CFG_INTERVAL_SECONDS ))
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Reading and writing /etc/cachy-auto-update/cachy-auto-update.conf.
|
||||
#
|
||||
# The file is deliberately *parsed* rather than sourced: it is edited by a
|
||||
# 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.
|
||||
|
||||
# cau_config_get <Key> [default]
|
||||
cau_config_get() {
|
||||
local key="$1" default="${2:-}" val
|
||||
|
||||
[[ -r $CAU_CONFIG ]] || { printf '%s\n' "$default"; return; }
|
||||
|
||||
val="$(sed -nE "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*(.*)$/\\1/p" \
|
||||
"$CAU_CONFIG" 2>/dev/null | tail -n1)"
|
||||
|
||||
# strip a trailing comment and surrounding whitespace/quotes
|
||||
val="${val%%#*}"
|
||||
val="${val#"${val%%[![:space:]]*}"}"
|
||||
val="${val%"${val##*[![:space:]]}"}"
|
||||
val="${val%\"}"
|
||||
val="${val#\"}"
|
||||
|
||||
if [[ -n $val ]]; then
|
||||
printf '%s\n' "$val"
|
||||
else
|
||||
printf '%s\n' "$default"
|
||||
fi
|
||||
}
|
||||
|
||||
# cau_config_bool <Key> <default: yes|no>
|
||||
# Succeeds when the key is truthy.
|
||||
cau_config_bool() {
|
||||
local val
|
||||
val="$(cau_config_get "$1" "$2")"
|
||||
case "${val,,}" in
|
||||
yes|y|true|1|on|enabled) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# cau_config_int <Key> <default>
|
||||
cau_config_int() {
|
||||
local val
|
||||
val="$(cau_config_get "$1" "$2")"
|
||||
[[ $val =~ ^-?[0-9]+$ ]] && printf '%s\n' "$val" || printf '%s\n' "$2"
|
||||
}
|
||||
|
||||
# cau_config_set <Key> <Value>
|
||||
# Replaces the key in place if present (including a commented-out template
|
||||
# line), otherwise appends it. Requires write access to the config file.
|
||||
cau_config_set() {
|
||||
local key="$1" value="$2" tmp
|
||||
|
||||
if [[ ! -e $CAU_CONFIG ]]; then
|
||||
mkdir -p "$(dirname "$CAU_CONFIG")" || return 1
|
||||
printf '# %s configuration\n' "$CAU_NAME" > "$CAU_CONFIG" || return 1
|
||||
fi
|
||||
[[ -w $CAU_CONFIG ]] || return 1
|
||||
|
||||
tmp="$(mktemp "${CAU_CONFIG}.XXXXXX")" || return 1
|
||||
# keep the original permissions rather than mktemp's 0600
|
||||
chmod --reference="$CAU_CONFIG" "$tmp" 2>/dev/null || chmod 0644 "$tmp"
|
||||
|
||||
if grep -qE "^[[:space:]]*#?[[:space:]]*${key}[[:space:]]*=" "$CAU_CONFIG"; then
|
||||
awk -v key="$key" -v value="$value" '
|
||||
!done && $0 ~ "^[[:space:]]*#?[[:space:]]*" key "[[:space:]]*=" {
|
||||
print key "=" value; done = 1; next
|
||||
}
|
||||
# drop any further occurrences so the file cannot grow duplicates
|
||||
$0 ~ "^[[:space:]]*" key "[[:space:]]*=" { next }
|
||||
{ print }
|
||||
' "$CAU_CONFIG" > "$tmp" || { rm -f "$tmp"; return 1; }
|
||||
else
|
||||
cat "$CAU_CONFIG" > "$tmp" || { rm -f "$tmp"; return 1; }
|
||||
printf '%s=%s\n' "$key" "$value" >> "$tmp"
|
||||
fi
|
||||
|
||||
mv -f "$tmp" "$CAU_CONFIG"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolved settings
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loaded once per process into plain variables so the rest of the code does not
|
||||
# re-parse the file for every lookup.
|
||||
|
||||
cau_config_load() {
|
||||
CFG_ENABLED=no; cau_config_bool Enabled no && CFG_ENABLED=yes
|
||||
CFG_NOTIFICATIONS=no; cau_config_bool Notifications yes && CFG_NOTIFICATIONS=yes
|
||||
CFG_REQUIRE_AC=no; cau_config_bool RequireAC no && CFG_REQUIRE_AC=yes
|
||||
CFG_SKIP_GAMING=no; cau_config_bool SkipWhenGaming yes && CFG_SKIP_GAMING=yes
|
||||
CFG_AUR=no; cau_config_bool UpdateAUR yes && CFG_AUR=yes
|
||||
CFG_FLATPAK=no; cau_config_bool UpdateFlatpak yes && CFG_FLATPAK=yes
|
||||
CFG_APPIMAGE=no; cau_config_bool UpdateAppImages yes && CFG_APPIMAGE=yes
|
||||
CFG_DEVEL=no; cau_config_bool UpdateDevel no && CFG_DEVEL=yes
|
||||
CFG_RESOLVE_CONFLICTS=no; cau_config_bool AutoResolveConflicts yes && CFG_RESOLVE_CONFLICTS=yes
|
||||
CFG_CLEAN_CACHE=no; cau_config_bool CleanCache no && CFG_CLEAN_CACHE=yes
|
||||
CFG_REMOVE_ORPHANS=no; cau_config_bool RemoveOrphans no && CFG_REMOVE_ORPHANS=yes
|
||||
CFG_NOTIFY_SUCCESS=no; cau_config_bool NotifyOnSuccess yes && CFG_NOTIFY_SUCCESS=yes
|
||||
CFG_NOTIFY_ERROR=no; cau_config_bool NotifyOnError yes && CFG_NOTIFY_ERROR=yes
|
||||
CFG_NOTIFY_REBOOT=no; cau_config_bool NotifyReboot yes && CFG_NOTIFY_REBOOT=yes
|
||||
|
||||
CFG_INTERVAL="$(cau_config_get UpdateInterval 1d)"
|
||||
CFG_INTERVAL_SECONDS="$(cau_duration_to_seconds "$CFG_INTERVAL" 86400)"
|
||||
CFG_MIN_BATTERY="$(cau_config_int MinBatteryPercent 40)"
|
||||
CFG_KEEP_OLD="$(cau_config_int KeepOldPackages 3)"
|
||||
CFG_AUR_HELPER="$(cau_config_get AURHelper auto)"
|
||||
CFG_IGNORE_PKG="$(cau_config_get IgnorePkg '')"
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Staying out of the way of a human using pacman/yay/paru.
|
||||
#
|
||||
# The contract this file implements: the machine's owner may run any package
|
||||
# manager at any time and must never see a lock error caused by us. We can only
|
||||
# guarantee that in one direction - by never *starting* while somebody else is
|
||||
# mid-transaction - so the checks here run before anything is touched, and a
|
||||
# refusal simply defers the run to the next hourly tick.
|
||||
|
||||
# Package managers that take /var/lib/pacman/db.lck. checkupdates is absent on
|
||||
# purpose: it works against a private temporary database and never touches the
|
||||
# real lock.
|
||||
CAU_PACKAGE_MANAGERS=(
|
||||
pacman pacman-static
|
||||
yay paru pikaur aurman trizen
|
||||
pamac pamac-daemon pamac-manager pamac-tray
|
||||
octopi octopi-helper
|
||||
makepkg
|
||||
)
|
||||
|
||||
# cau_acquire_lock
|
||||
# Guards against two runs overlapping (a slow run still going when the next
|
||||
# tick fires). Non-blocking: if another run holds it, this one is pointless.
|
||||
cau_acquire_lock() {
|
||||
mkdir -p "$CAU_RUNDIR" 2>/dev/null || return 1
|
||||
|
||||
exec {CAU_LOCK_FD}> "$CAU_LOCKFILE" || return 1
|
||||
flock -n "$CAU_LOCK_FD" || return 1
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# cau_pacman_lock_holder
|
||||
# Prints the pids currently holding pacman's database lock, if any.
|
||||
cau_pacman_lock_holder() {
|
||||
[[ -e $CAU_PACMAN_LOCK ]] || return 1
|
||||
cau_have fuser || return 0 # cannot tell; assume it is held
|
||||
fuser "$CAU_PACMAN_LOCK" 2>/dev/null | tr -s ' ' '\n' | grep -E '^[0-9]+$' || return 0
|
||||
}
|
||||
|
||||
# cau_package_manager_busy
|
||||
# True when somebody else is using the package system right now. Sets
|
||||
# CAU_SKIP_REASON.
|
||||
cau_package_manager_busy() {
|
||||
local pm
|
||||
|
||||
if [[ -e $CAU_PACMAN_LOCK ]]; then
|
||||
CAU_SKIP_REASON="$(cau_msg "pacman's database is locked by another process")"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for pm in "${CAU_PACKAGE_MANAGERS[@]}"; do
|
||||
if cau_process_running "$pm"; then
|
||||
CAU_SKIP_REASON="$(cau_msg "%s is currently running" "$pm")"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# 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.
|
||||
CAU_STALE_LOCK_RUNS=3
|
||||
|
||||
cau_track_stale_lock() {
|
||||
local count
|
||||
|
||||
if [[ ! -e $CAU_PACMAN_LOCK ]]; then
|
||||
cau_state_clear stale_lock_count
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -n "$(cau_pacman_lock_holder)" ]]; then
|
||||
# genuinely held; not stale
|
||||
cau_state_clear stale_lock_count
|
||||
return 1
|
||||
fi
|
||||
|
||||
count="$(cau_state_read stale_lock_count 0)"
|
||||
[[ $count =~ ^[0-9]+$ ]] || count=0
|
||||
count=$(( count + 1 ))
|
||||
cau_state_write stale_lock_count "$count"
|
||||
|
||||
(( count >= CAU_STALE_LOCK_RUNS ))
|
||||
}
|
||||
|
||||
# cau_inhibit_prefix
|
||||
# Builds the command prefix that keeps logind from suspending or shutting the
|
||||
# machine down mid-transaction. Falls back to running unprotected rather than
|
||||
# refusing to update at all.
|
||||
cau_inhibit_prefix() {
|
||||
if cau_have systemd-inhibit; then
|
||||
printf '%s\n' systemd-inhibit
|
||||
printf '%s\n' --what=sleep:shutdown
|
||||
printf '%s\n' --mode=block
|
||||
printf '%s\n' "--who=$CAU_PRETTY"
|
||||
printf '%s\n' "--why=$(cau_msg_in C "applying system updates")"
|
||||
printf '%s\n' --
|
||||
fi
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# The interactive front end.
|
||||
#
|
||||
# This is the part the machine's owner actually sees, so it stays deliberately
|
||||
# small: two switches, a status block, and a way to look at what happened.
|
||||
|
||||
CAU_UNIT="cachy-auto-update.timer"
|
||||
|
||||
# _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
|
||||
# here instead - and applied inline, because command substitution would eat the
|
||||
# trailing spaces again.
|
||||
_cau_row() {
|
||||
local label="$1" value="$2" pad
|
||||
pad=$(( 28 - ${#label} ))
|
||||
(( pad < 0 )) && pad=0
|
||||
printf ' %s%*s %s\n' "$label" "$pad" '' "$value"
|
||||
}
|
||||
|
||||
_cau_onoff() {
|
||||
if [[ $1 == yes ]]; 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
|
||||
}
|
||||
|
||||
# cau_timer_next
|
||||
# When systemd will fire the timer next, in the user's locale.
|
||||
cau_timer_next() {
|
||||
local raw
|
||||
|
||||
cau_have systemctl || { cau_msg "unknown"; return; }
|
||||
systemctl is-enabled --quiet "$CAU_UNIT" 2>/dev/null || { cau_msg "not scheduled"; return; }
|
||||
|
||||
raw="$(systemctl show "$CAU_UNIT" --property=NextElapseUSecRealtime --value 2>/dev/null)"
|
||||
|
||||
if [[ $raw =~ ^[0-9]+$ ]] && (( raw > 0 )); then
|
||||
date -d "@$(( raw / 1000000 ))" '+%c'
|
||||
elif [[ -n $raw && $raw != 0 && $raw != n/a ]]; then
|
||||
printf '%s' "$raw"
|
||||
else
|
||||
cau_msg "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
# cau_ui_status
|
||||
# Shared by the `status` subcommand and the menu header.
|
||||
cau_ui_status() {
|
||||
local last_run last_success result counts reboot
|
||||
local pac aur fp ai total
|
||||
|
||||
last_run="$(cau_state_read last_run '')"
|
||||
last_success="$(cau_state_read last_success '')"
|
||||
result="$(cau_state_read last_result '')"
|
||||
counts="$(cau_state_read last_counts '0 0 0 0')"
|
||||
reboot="$(cau_state_read reboot_needed 0)"
|
||||
|
||||
read -r pac aur fp ai <<< "$counts"
|
||||
pac=${pac:-0}; aur=${aur:-0}; fp=${fp:-0}; ai=${ai:-0}
|
||||
total=$(( pac + aur + fp + ai ))
|
||||
|
||||
_cau_row "$(cau_msg "Automatic updates")" "$(_cau_onoff "$CFG_ENABLED")"
|
||||
_cau_row "$(cau_msg "Notifications")" "$(_cau_onoff "$CFG_NOTIFICATIONS")"
|
||||
printf '\n'
|
||||
|
||||
_cau_row "$(cau_msg "Last check")" "$(cau_time_ago "$last_run")"
|
||||
|
||||
if [[ -n $last_success ]]; then
|
||||
_cau_row "$(cau_msg "Last successful update")" \
|
||||
"$(date -d "@$last_success" '+%c' 2>/dev/null || printf '%s' "$last_success") ($(cau_msg "%d packages" "$total"))"
|
||||
else
|
||||
_cau_row "$(cau_msg "Last successful update")" "$(cau_msg "never")"
|
||||
fi
|
||||
|
||||
_cau_row "$(cau_msg "Next scheduled run")" "$(cau_timer_next)"
|
||||
|
||||
if [[ $result == failed ]]; then
|
||||
printf '\n %s%s%s\n' "$CAU_C_YELLOW" \
|
||||
"$(cau_msg "The last run reported a problem - see 'cachy-auto-update log'.")" \
|
||||
"$CAU_C_RESET"
|
||||
fi
|
||||
if [[ $reboot == 1 ]]; then
|
||||
printf '\n %s%s%s\n' "$CAU_C_YELLOW" \
|
||||
"$(cau_msg "A restart is recommended to finish a kernel update.")" \
|
||||
"$CAU_C_RESET"
|
||||
fi
|
||||
}
|
||||
|
||||
# cau_ui_status_conditions
|
||||
# Evaluates every gate individually. Without this there is no way to explain
|
||||
# why an enabled updater has not done anything on somebody else's machine.
|
||||
cau_ui_status_conditions() {
|
||||
local pct
|
||||
|
||||
printf '\n %s\n' "$(cau_msg "Current conditions:")"
|
||||
|
||||
if cau_on_ac; then
|
||||
printf ' %s %s\n' "${CAU_C_GREEN}✔${CAU_C_RESET}" "$(cau_msg "on AC power")"
|
||||
else
|
||||
pct="$(cau_battery_percent || true)"
|
||||
if [[ -n $pct ]]; then
|
||||
printf ' %s %s\n' "${CAU_C_DIM}•${CAU_C_RESET}" \
|
||||
"$(cau_msg "on battery (%d%%, threshold %d%%)" "$pct" "$CFG_MIN_BATTERY")"
|
||||
else
|
||||
printf ' %s %s\n' "${CAU_C_DIM}•${CAU_C_RESET}" "$(cau_msg "on battery")"
|
||||
fi
|
||||
fi
|
||||
|
||||
CAU_SKIP_REASON=''
|
||||
if cau_busy; then
|
||||
printf ' %s %s\n' "${CAU_C_YELLOW}•${CAU_C_RESET}" "$CAU_SKIP_REASON"
|
||||
else
|
||||
printf ' %s %s\n' "${CAU_C_GREEN}✔${CAU_C_RESET}" "$(cau_msg "no game detected")"
|
||||
fi
|
||||
|
||||
CAU_SKIP_REASON=''
|
||||
if cau_package_manager_busy; then
|
||||
printf ' %s %s\n' "${CAU_C_YELLOW}•${CAU_C_RESET}" "$CAU_SKIP_REASON"
|
||||
else
|
||||
printf ' %s %s\n' "${CAU_C_GREEN}✔${CAU_C_RESET}" "$(cau_msg "package system is free")"
|
||||
fi
|
||||
}
|
||||
|
||||
# cau_ui_menu
|
||||
# The default view. Reloads the config after every action so the toggles always
|
||||
# reflect what is actually on disk.
|
||||
cau_ui_menu() {
|
||||
local choice
|
||||
|
||||
while true; do
|
||||
cau_config_load
|
||||
|
||||
clear 2>/dev/null || true
|
||||
cau_head " $CAU_PRETTY"
|
||||
cau_ui_status
|
||||
printf '\n'
|
||||
printf ' [1] %s\n' "$(cau_msg "Toggle automatic updates")"
|
||||
printf ' [2] %s\n' "$(cau_msg "Toggle notifications")"
|
||||
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 ' [q] %s\n' "$(cau_msg "Quit")"
|
||||
printf '\n > '
|
||||
|
||||
read -r choice || { printf '\n'; return 0; }
|
||||
|
||||
case "$choice" in
|
||||
1)
|
||||
if [[ $CFG_ENABLED == yes ]]; then cau_do_disable; else cau_do_enable; fi
|
||||
cau_pause
|
||||
;;
|
||||
2)
|
||||
if [[ $CFG_NOTIFICATIONS == yes ]]; then
|
||||
cau_do_notifications off
|
||||
else
|
||||
cau_do_notifications on
|
||||
fi
|
||||
cau_pause
|
||||
;;
|
||||
3) cau_do_run --force; cau_pause ;;
|
||||
4) cau_do_log; cau_pause ;;
|
||||
5) cau_ui_status_conditions; cau_pause ;;
|
||||
q|Q|'') return 0 ;;
|
||||
*) ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
cau_pause() {
|
||||
printf '\n %s' "$(cau_msg "Press Enter to continue...")"
|
||||
read -r _ || true
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Desktop notifications from a root system service.
|
||||
#
|
||||
# Two paths exist:
|
||||
# * live - somebody has a graphical session, so notify-send is run inside
|
||||
# it via runuser with the session bus address set;
|
||||
# * queued - nobody is logged in, so the message is appended to a spool that
|
||||
# the XDG autostart entry replays at the next login.
|
||||
#
|
||||
# Messages travel as a msgid plus printf arguments rather than as finished
|
||||
# text, so a notification queued at 04:00 is still rendered in whatever locale
|
||||
# the user's desktop turns out to be running in.
|
||||
|
||||
CAU_NOTIFY_ICON="system-software-update"
|
||||
CAU_NOTIFY_QUEUE_MAX=20
|
||||
|
||||
# cau_notify <urgency> <title-msgid> <body-msgid> [body printf args...]
|
||||
# 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
|
||||
local -a args=("$@")
|
||||
local delivered=0 user uid locale t b
|
||||
|
||||
[[ $CFG_NOTIFICATIONS == yes ]] || return 0
|
||||
|
||||
while read -r user uid; do
|
||||
[[ -n $user ]] || continue
|
||||
cau_as_user "$user" "$uid" sh -c 'command -v notify-send >/dev/null' || continue
|
||||
|
||||
locale="$(cau_user_locale "$user" "$uid")"
|
||||
t="$(cau_msg_in "$locale" "$title")"
|
||||
b="$(cau_msg_in "$locale" "$body" "${args[@]}")"
|
||||
|
||||
if cau_as_user "$user" "$uid" notify-send \
|
||||
--app-name="$CAU_PRETTY" \
|
||||
--icon="$CAU_NOTIFY_ICON" \
|
||||
--urgency="$urgency" \
|
||||
-- "$t" "$b" 2>/dev/null
|
||||
then
|
||||
delivered=1
|
||||
fi
|
||||
done < <(cau_active_session_users)
|
||||
|
||||
(( delivered )) && return 0
|
||||
|
||||
cau_notify_enqueue "$urgency" "$title" "$body" "${args[@]}"
|
||||
}
|
||||
|
||||
# cau_notify_enqueue <urgency> <title-msgid> <body-msgid> [args...]
|
||||
# Tab-separated records, oldest first. The file is world-readable on purpose:
|
||||
# the login-time delivery runs unprivileged and only ever reads it.
|
||||
#
|
||||
# The key is a nanosecond timestamp rather than a second one: delivery marks
|
||||
# progress by "last key seen", so two records sharing a key could make the
|
||||
# second one unreachable forever if a login landed between them.
|
||||
cau_notify_enqueue() {
|
||||
local urgency="$1" title="$2" body="$3"
|
||||
shift 3
|
||||
local record tmp
|
||||
|
||||
mkdir -p "$CAU_STATEDIR" 2>/dev/null || return 0
|
||||
|
||||
record="$(date +%s%N)"$'\t'"$urgency"$'\t'"$title"$'\t'"$body"
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
record+=$'\t'"${arg//$'\t'/ }"
|
||||
done
|
||||
|
||||
printf '%s\n' "$record" >> "$CAU_NOTIFY_QUEUE" 2>/dev/null || return 0
|
||||
|
||||
# keep the spool bounded - nobody wants three weeks of backlog at login
|
||||
if (( $(wc -l < "$CAU_NOTIFY_QUEUE" 2>/dev/null || echo 0) > CAU_NOTIFY_QUEUE_MAX )); then
|
||||
tmp="$(mktemp "${CAU_NOTIFY_QUEUE}.XXXXXX")" || return 0
|
||||
tail -n "$CAU_NOTIFY_QUEUE_MAX" "$CAU_NOTIFY_QUEUE" > "$tmp"
|
||||
mv -f "$tmp" "$CAU_NOTIFY_QUEUE"
|
||||
fi
|
||||
chmod 0644 "$CAU_NOTIFY_QUEUE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# cau_notify_deliver_queue
|
||||
# Runs unprivileged, as the freshly logged-in user, from the XDG autostart
|
||||
# entry. State about what has already been seen lives in the user's own home,
|
||||
# so no write access to /var/lib is needed and each user is tracked separately.
|
||||
cau_notify_deliver_queue() {
|
||||
local seen_file seen ts urgency title body
|
||||
local -a args
|
||||
|
||||
[[ -r $CAU_NOTIFY_QUEUE ]] || return 0
|
||||
cau_have notify-send || return 0
|
||||
|
||||
seen_file="${XDG_STATE_HOME:-$HOME/.local/state}/cachy-auto-update/notify-seen"
|
||||
mkdir -p "$(dirname "$seen_file")" 2>/dev/null || return 0
|
||||
|
||||
seen=0
|
||||
[[ -r $seen_file ]] && seen="$(< "$seen_file")"
|
||||
[[ $seen =~ ^[0-9]+$ ]] || seen=0
|
||||
|
||||
local newest="$seen"
|
||||
while IFS=$'\t' read -r ts urgency title body rest; do
|
||||
[[ $ts =~ ^[0-9]+$ ]] || continue
|
||||
(( ts > seen )) || continue
|
||||
|
||||
# remaining tab-separated fields are the body's printf arguments
|
||||
args=()
|
||||
if [[ -n ${rest:-} ]]; then
|
||||
IFS=$'\t' read -r -a args <<< "$rest"
|
||||
fi
|
||||
|
||||
notify-send \
|
||||
--app-name="$CAU_PRETTY" \
|
||||
--icon="$CAU_NOTIFY_ICON" \
|
||||
--urgency="${urgency:-normal}" \
|
||||
-- "$(cau_msg "$title")" "$(cau_msg "$body" "${args[@]}")" 2>/dev/null || true
|
||||
|
||||
(( ts > newest )) && newest="$ts"
|
||||
done < "$CAU_NOTIFY_QUEUE"
|
||||
|
||||
printf '%s\n' "$newest" > "$seen_file" 2>/dev/null || true
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# AppImages, by way of Gear Lever.
|
||||
#
|
||||
# AppImages have no package manager of their own; Gear Lever is what tracks
|
||||
# where each one came from and how to fetch a new build. Its CLI is a first
|
||||
# class interface - `--update --all -y` is exactly the unattended entry point
|
||||
# we need, and it skips AppImages whose application is currently running rather
|
||||
# than pulling the file out from under it (we deliberately do not pass
|
||||
# --force).
|
||||
#
|
||||
# This only runs for users with a live graphical session: Gear Lever is a
|
||||
# Flatpak GTK application and needs the session's runtime directory. Nothing is
|
||||
# lost by waiting - the next hourly tick will catch it once they log in.
|
||||
|
||||
CAU_APPIMAGE_ID="it.mijorus.gearlever"
|
||||
CAU_APPIMAGE_COUNT=0
|
||||
|
||||
# _cau_gearlever_cmd <user> <uid>
|
||||
# Prints the argv prefix that invokes Gear Lever for that user, or nothing when
|
||||
# it is not installed for them.
|
||||
_cau_gearlever_cmd() {
|
||||
local user="$1" uid="$2"
|
||||
|
||||
if cau_as_user "$user" "$uid" sh -c 'command -v gearlever >/dev/null 2>&1'; then
|
||||
printf '%s\n' gearlever
|
||||
return 0
|
||||
fi
|
||||
|
||||
if cau_have flatpak && \
|
||||
cau_as_user "$user" "$uid" flatpak info "$CAU_APPIMAGE_ID" > /dev/null 2>&1
|
||||
then
|
||||
printf '%s\n' flatpak run "--command=gearlever" "$CAU_APPIMAGE_ID"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# cau_appimage_update
|
||||
# Never fatal: a headless GTK application is a best-effort proposition.
|
||||
cau_appimage_update() {
|
||||
local user uid count rc=0
|
||||
local -a cmd
|
||||
|
||||
while read -r user uid; do
|
||||
[[ -n $user ]] || continue
|
||||
|
||||
mapfile -t cmd < <(_cau_gearlever_cmd "$user" "$uid")
|
||||
(( ${#cmd[@]} )) || continue
|
||||
|
||||
# "[Update available, <manager>]" is the per-app marker in the plain
|
||||
# text listing; the alternative is --json, which would drag in a JSON
|
||||
# parser for no gain.
|
||||
count="$(cau_as_user "$user" "$uid" timeout 300 "${cmd[@]}" --list-updates 2>/dev/null \
|
||||
| grep -c '\[Update available')" || count=0
|
||||
[[ $count =~ ^[0-9]+$ ]] || count=0
|
||||
|
||||
if (( count == 0 )); then
|
||||
cau_info "No AppImage updates pending for $user"
|
||||
continue
|
||||
fi
|
||||
|
||||
cau_info "Updating $count AppImage(s) for $user"
|
||||
if cau_run_logged cau_as_user "$user" "$uid" timeout 1800 \
|
||||
"${cmd[@]}" --update --all -y
|
||||
then
|
||||
CAU_APPIMAGE_COUNT=$(( CAU_APPIMAGE_COUNT + count ))
|
||||
else
|
||||
cau_warn "AppImage update failed for $user"
|
||||
rc=1
|
||||
fi
|
||||
done < <(cau_active_session_users)
|
||||
|
||||
return $rc
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# AUR packages.
|
||||
#
|
||||
# makepkg - and therefore paru and yay - refuse to run as root, so this is the
|
||||
# one part of the run that cannot happen in the service's own context. It is
|
||||
# executed as the locked "cachy-auto-update" system account instead, which
|
||||
# sysusers.d creates with no password and no shell. That account is granted
|
||||
# NOPASSWD access to /usr/bin/pacman through /etc/sudoers.d/cachy-auto-update,
|
||||
# which is what lets the helper install what it built without a human present.
|
||||
#
|
||||
# The alternative - stashing the user's password somewhere the daemon can read
|
||||
# it - buys nothing: whatever can decrypt it is exactly what an attacker would
|
||||
# already have.
|
||||
|
||||
CAU_AUR_COUNT=0
|
||||
CAU_AUR_HELPER=''
|
||||
|
||||
# Notify only after this many consecutive failed AUR runs. A single failed
|
||||
# build is routine (upstream broke a tarball, a checksum moved) and self-heals
|
||||
# a day later; nagging about it on someone's parents' machine is noise.
|
||||
CAU_AUR_FAILURE_THRESHOLD=2
|
||||
|
||||
# cau_aur_detect
|
||||
# Resolves the helper to use, honouring AURHelper from the config.
|
||||
cau_aur_detect() {
|
||||
local candidate
|
||||
|
||||
CAU_AUR_HELPER=''
|
||||
|
||||
if [[ -n $CFG_AUR_HELPER && $CFG_AUR_HELPER != auto ]]; then
|
||||
if cau_have "$CFG_AUR_HELPER"; then
|
||||
CAU_AUR_HELPER="$CFG_AUR_HELPER"
|
||||
return 0
|
||||
fi
|
||||
cau_warn "Configured AUR helper '$CFG_AUR_HELPER' not found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
for candidate in paru yay pikaur; do
|
||||
if cau_have "$candidate"; then
|
||||
CAU_AUR_HELPER="$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# cau_as_build_user <command> [args...]
|
||||
# A deliberately small environment: the helper gets its own HOME and cache so
|
||||
# nothing it downloads ever lands in a human's home directory.
|
||||
cau_as_build_user() {
|
||||
runuser -u "$CAU_BUILD_USER" -- env \
|
||||
"HOME=$CAU_BUILD_HOME" \
|
||||
"USER=$CAU_BUILD_USER" \
|
||||
"LOGNAME=$CAU_BUILD_USER" \
|
||||
"XDG_CACHE_HOME=$CAU_CACHEDIR" \
|
||||
"XDG_CONFIG_HOME=$CAU_BUILD_HOME/.config" \
|
||||
"XDG_DATA_HOME=$CAU_BUILD_HOME/.local/share" \
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/bin" \
|
||||
LC_ALL=C \
|
||||
"$@"
|
||||
}
|
||||
|
||||
# cau_aur_ready
|
||||
# True when everything the AUR path needs is actually in place.
|
||||
cau_aur_ready() {
|
||||
cau_aur_detect || { cau_info "No AUR helper installed; skipping AUR updates"; return 1; }
|
||||
|
||||
if ! getent passwd "$CAU_BUILD_USER" > /dev/null; then
|
||||
cau_warn "Build account '$CAU_BUILD_USER' is missing; skipping AUR updates"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# -l asks sudo whether the command is permitted; -n guarantees it can
|
||||
# never block on a password prompt. Testing `sudo -n true` instead would
|
||||
# fail by design, because the rule is scoped to pacman alone.
|
||||
if ! cau_as_build_user sudo -n -l /usr/bin/pacman > /dev/null 2>&1; then
|
||||
cau_warn "Build account cannot run pacman without a password; check /etc/sudoers.d/cachy-auto-update"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! cau_have makepkg; then
|
||||
cau_warn "makepkg not found (base-devel missing); skipping AUR updates"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# cau_aur_helper_args
|
||||
# The flags that turn an interactive helper into a silent one.
|
||||
cau_aur_helper_args() {
|
||||
case "$CAU_AUR_HELPER" in
|
||||
paru)
|
||||
printf '%s\n' -Sua --noconfirm --skipreview --removemake --cleanafter --color never
|
||||
[[ $CFG_DEVEL == yes ]] && printf '%s\n' --devel
|
||||
;;
|
||||
yay)
|
||||
printf '%s\n' -Sua --noconfirm --removemake --cleanafter --color never
|
||||
printf '%s\n' --answerclean All --answerdiff None --answeredit None --answerupgrade None
|
||||
[[ $CFG_DEVEL == yes ]] && printf '%s\n' --devel
|
||||
;;
|
||||
pikaur)
|
||||
printf '%s\n' -Sua --noconfirm --noedit
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# cau_aur_pending
|
||||
# Number of AUR packages with an update available.
|
||||
cau_aur_pending() {
|
||||
local out
|
||||
out="$(cau_as_build_user "$CAU_AUR_HELPER" -Qua 2>/dev/null | grep -c .)" || out=0
|
||||
[[ $out =~ ^[0-9]+$ ]] || out=0
|
||||
printf '%s\n' "$out"
|
||||
}
|
||||
|
||||
# cau_aur_update
|
||||
# Returns 0 on success or "nothing to do", 1 on a failure worth reporting.
|
||||
# Transient build failures are swallowed until they repeat.
|
||||
cau_aur_update() {
|
||||
local pending failures
|
||||
local -a args
|
||||
|
||||
cau_aur_ready || return 0
|
||||
|
||||
pending="$(cau_aur_pending)"
|
||||
if (( pending == 0 )); then
|
||||
cau_info "No AUR updates pending"
|
||||
cau_state_clear aur_failures
|
||||
return 0
|
||||
fi
|
||||
|
||||
cau_info "Updating $pending AUR package(s) with $CAU_AUR_HELPER"
|
||||
mapfile -t args < <(cau_aur_helper_args)
|
||||
|
||||
if cau_run_logged cau_as_build_user "$CAU_AUR_HELPER" "${args[@]}"; then
|
||||
CAU_AUR_COUNT="$pending"
|
||||
cau_state_clear aur_failures
|
||||
return 0
|
||||
fi
|
||||
|
||||
CAU_AUR_COUNT=0
|
||||
failures="$(cau_state_read aur_failures 0)"
|
||||
[[ $failures =~ ^[0-9]+$ ]] || failures=0
|
||||
failures=$(( failures + 1 ))
|
||||
cau_state_write aur_failures "$failures"
|
||||
|
||||
if (( failures >= CAU_AUR_FAILURE_THRESHOLD )); then
|
||||
cau_error "AUR update failed $failures times in a row"
|
||||
return 1
|
||||
fi
|
||||
|
||||
cau_warn "AUR update failed (attempt $failures); will retry on the next run"
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Flatpak.
|
||||
#
|
||||
# System-wide installations are updated directly as root. That side-steps a
|
||||
# real obstacle: the shipped polkit rule for Flatpak only grants install and
|
||||
# uninstall, and only to a subject that is active, local and in the wheel
|
||||
# group - none of which is true for an unattended service. Being root means
|
||||
# polkit is never consulted in the first place.
|
||||
#
|
||||
# Per-user installations live in the user's home and are updated inside their
|
||||
# own account.
|
||||
|
||||
CAU_FLATPAK_COUNT=0
|
||||
|
||||
# cau_flatpak_pending_system
|
||||
cau_flatpak_pending_system() {
|
||||
local out
|
||||
out="$(flatpak remote-ls --system --updates --columns=application 2>/dev/null | grep -c .)" || out=0
|
||||
[[ $out =~ ^[0-9]+$ ]] || out=0
|
||||
printf '%s\n' "$out"
|
||||
}
|
||||
|
||||
# cau_flatpak_update
|
||||
# Failures are reported but never abort the rest of the run.
|
||||
cau_flatpak_update() {
|
||||
local rc=0 pending user uid home count
|
||||
|
||||
cau_have flatpak || return 0
|
||||
|
||||
# refresh appstream metadata first so remote-ls sees current versions
|
||||
cau_run_logged flatpak update --appstream --system --noninteractive || true
|
||||
|
||||
pending="$(cau_flatpak_pending_system)"
|
||||
if (( pending > 0 )); then
|
||||
cau_info "Updating $pending system Flatpak(s)"
|
||||
if cau_run_logged flatpak update --system --noninteractive --assumeyes; then
|
||||
CAU_FLATPAK_COUNT=$(( CAU_FLATPAK_COUNT + pending ))
|
||||
else
|
||||
cau_warn "System Flatpak update failed"
|
||||
rc=1
|
||||
fi
|
||||
else
|
||||
cau_info "No system Flatpak updates pending"
|
||||
fi
|
||||
|
||||
while read -r user uid home; do
|
||||
[[ -d "$home/.local/share/flatpak" ]] || continue
|
||||
|
||||
count="$(cau_as_user "$user" "$uid" flatpak remote-ls --user --updates \
|
||||
--columns=application 2>/dev/null | grep -c .)" || count=0
|
||||
[[ $count =~ ^[0-9]+$ ]] || count=0
|
||||
(( count > 0 )) || continue
|
||||
|
||||
cau_info "Updating $count user Flatpak(s) for $user"
|
||||
if cau_run_logged cau_as_user "$user" "$uid" \
|
||||
flatpak update --user --noninteractive --assumeyes
|
||||
then
|
||||
CAU_FLATPAK_COUNT=$(( CAU_FLATPAK_COUNT + count ))
|
||||
else
|
||||
cau_warn "User Flatpak update failed for $user"
|
||||
rc=1
|
||||
fi
|
||||
done < <(cau_human_users)
|
||||
|
||||
if [[ $CFG_CLEAN_CACHE == yes ]]; then
|
||||
cau_run_logged flatpak uninstall --system --unused --noninteractive --assumeyes || true
|
||||
fi
|
||||
|
||||
return $rc
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Repository packages.
|
||||
#
|
||||
# The whole step is skipped when checkupdates reports nothing pending, which
|
||||
# matters more than it looks: checkupdates works against a private temporary
|
||||
# database, so on the common "nothing to do" day the real pacman lock is never
|
||||
# taken at all and a human using pacman is never inconvenienced.
|
||||
|
||||
CAU_PACMAN_COUNT=0
|
||||
CAU_PACMAN_PENDING=''
|
||||
|
||||
# Base flags for every unattended pacman invocation.
|
||||
cau_pacman_flags() {
|
||||
printf '%s\n' --noconfirm --color never --noprogressbar --disable-download-timeout
|
||||
local pkg
|
||||
for pkg in $CFG_IGNORE_PKG; do
|
||||
printf '%s\n' --ignore "$pkg"
|
||||
done
|
||||
}
|
||||
|
||||
# cau_pacman_pending
|
||||
# Fills CAU_PACMAN_PENDING and returns 1 when there is nothing to do.
|
||||
cau_pacman_pending() {
|
||||
local db
|
||||
|
||||
cau_have checkupdates || {
|
||||
# Without pacman-contrib we cannot look ahead cheaply; assume work.
|
||||
CAU_PACMAN_PENDING=''
|
||||
return 0
|
||||
}
|
||||
|
||||
# Kept out of CAU_CACHEDIR, which belongs to the unprivileged build
|
||||
# account; this one is written by root. pacman keeps its own sync
|
||||
# databases under /var/lib too.
|
||||
db="${CAU_STATEDIR}/checkupdates-db"
|
||||
mkdir -p "$db" 2>/dev/null || db="${TMPDIR:-/tmp}/cachy-auto-update-checkupdates"
|
||||
|
||||
CAU_PACMAN_PENDING="$(CHECKUPDATES_DB="$db" checkupdates --nocolor 2>/dev/null)"
|
||||
local rc=$?
|
||||
|
||||
# exit 2 means "no updates", anything else non-zero is a lookup failure and
|
||||
# is treated as "might have work" so a transient network hiccup does not
|
||||
# silently skip the whole run
|
||||
if (( rc == 2 )) || [[ -z $CAU_PACMAN_PENDING ]]; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# _cau_pacman_classify <logfile>
|
||||
# Which kind of unattended failure was this?
|
||||
_cau_pacman_classify() {
|
||||
local log="$1"
|
||||
|
||||
if grep -qiE 'are in conflict|unresolvable package conflicts' "$log"; then
|
||||
printf 'conflict\n'
|
||||
elif grep -qiE 'signature from .* is (unknown trust|marginal trust|invalid)|invalid or corrupted package \(PGP signature\)|key ".*" is unknown|keyring is not writable' "$log"; then
|
||||
printf 'keyring\n'
|
||||
elif grep -qiE 'exists in filesystem' "$log"; then
|
||||
printf 'filesystem\n'
|
||||
else
|
||||
printf 'other\n'
|
||||
fi
|
||||
}
|
||||
|
||||
# cau_pacman_update
|
||||
# Returns 0 on success (including "nothing to do"), 1 on a failure the user
|
||||
# needs to hear about. CAU_PACMAN_COUNT holds how many packages moved.
|
||||
cau_pacman_update() {
|
||||
local log kind
|
||||
local -a flags
|
||||
|
||||
if ! cau_pacman_pending; then
|
||||
cau_info "No repository updates pending"
|
||||
return 0
|
||||
fi
|
||||
|
||||
CAU_PACMAN_COUNT="$(grep -c . <<< "$CAU_PACMAN_PENDING")"
|
||||
[[ $CAU_PACMAN_COUNT =~ ^[0-9]+$ ]] || CAU_PACMAN_COUNT=0
|
||||
cau_info "Updating $CAU_PACMAN_COUNT repository package(s)"
|
||||
|
||||
mapfile -t flags < <(cau_pacman_flags)
|
||||
log="$(mktemp)" || return 1
|
||||
|
||||
if pacman -Syu "${flags[@]}" > "$log" 2>&1; then
|
||||
cat "$log" >> "$CAU_RUNLOG" 2>/dev/null
|
||||
rm -f "$log"
|
||||
return 0
|
||||
fi
|
||||
|
||||
cat "$log" >> "$CAU_RUNLOG" 2>/dev/null
|
||||
kind="$(_cau_pacman_classify "$log")"
|
||||
cau_warn "pacman -Syu failed ($kind)"
|
||||
|
||||
case "$kind" in
|
||||
keyring)
|
||||
# A stale keyring is the one failure that is always safe to fix
|
||||
# automatically, and it blocks everything else until it is.
|
||||
cau_info "Refreshing keyrings and retrying"
|
||||
local -a keyrings=()
|
||||
pacman -Qq archlinux-keyring &> /dev/null && keyrings+=(archlinux-keyring)
|
||||
pacman -Qq cachyos-keyring &> /dev/null && keyrings+=(cachyos-keyring)
|
||||
if (( ${#keyrings[@]} )); then
|
||||
cau_run_logged pacman -Sy --noconfirm --color never "${keyrings[@]}" || true
|
||||
fi
|
||||
if pacman -Syu "${flags[@]}" > "$log" 2>&1; then
|
||||
cat "$log" >> "$CAU_RUNLOG" 2>/dev/null
|
||||
rm -f "$log"
|
||||
return 0
|
||||
fi
|
||||
cat "$log" >> "$CAU_RUNLOG" 2>/dev/null
|
||||
;;
|
||||
|
||||
conflict)
|
||||
# A package that has to replace another one. --noconfirm already
|
||||
# answers "Replace X with Y?" affirmatively; what it declines is
|
||||
# ":: X and Y are in conflict. Remove Y? [y/N]". --ask is pacman's
|
||||
# question bitmask: 4 = CONFLICT_PKG, 16 = REMOVE_PKGS.
|
||||
if [[ $CFG_RESOLVE_CONFLICTS != yes ]]; then
|
||||
cau_error "Package conflict requires a decision (AutoResolveConflicts is off)"
|
||||
rm -f "$log"
|
||||
return 1
|
||||
fi
|
||||
cau_info "Resolving package conflicts automatically and retrying"
|
||||
if pacman -Syu "${flags[@]}" --ask=20 > "$log" 2>&1; then
|
||||
cat "$log" >> "$CAU_RUNLOG" 2>/dev/null
|
||||
grep -E '^(removing|replacing) ' "$log" 2>/dev/null \
|
||||
| while read -r line; do cau_info " $line"; done
|
||||
rm -f "$log"
|
||||
return 0
|
||||
fi
|
||||
cat "$log" >> "$CAU_RUNLOG" 2>/dev/null
|
||||
;;
|
||||
|
||||
filesystem)
|
||||
# Untracked files in the way. Forcing --overwrite here could
|
||||
# silently clobber something the user put there deliberately, so
|
||||
# this one stays a human decision.
|
||||
cau_error "Files on disk conflict with the update; manual review needed"
|
||||
rm -f "$log"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
rm -f "$log"
|
||||
CAU_PACMAN_COUNT=0
|
||||
return 1
|
||||
}
|
||||
|
||||
# cau_pacman_reboot_needed
|
||||
# The running kernel's module tree no longer carries a vmlinuz, so the kernel
|
||||
# package was replaced underneath us.
|
||||
cau_pacman_reboot_needed() {
|
||||
[[ ! -f "/usr/lib/modules/$(uname -r)/vmlinuz" ]]
|
||||
}
|
||||
|
||||
# cau_pacman_pacnew_count
|
||||
# Purely informational; the files themselves are never touched.
|
||||
cau_pacman_pacnew_count() {
|
||||
cau_have pacdiff || { printf '0\n'; return; }
|
||||
pacdiff -o 2>/dev/null | grep -c . || printf '0\n'
|
||||
}
|
||||
|
||||
# cau_pacman_cleanup
|
||||
# Optional and off by default.
|
||||
cau_pacman_cleanup() {
|
||||
local -a orphans
|
||||
|
||||
if [[ $CFG_REMOVE_ORPHANS == yes ]]; then
|
||||
mapfile -t orphans < <(pacman -Qtdq 2>/dev/null)
|
||||
if (( ${#orphans[@]} )); then
|
||||
cau_info "Removing ${#orphans[@]} orphaned package(s)"
|
||||
cau_run_logged pacman -Rns --noconfirm --color never "${orphans[@]}" \
|
||||
|| cau_warn "Removing orphans failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $CFG_CLEAN_CACHE == yes ]] && cau_have paccache; then
|
||||
cau_info "Trimming the package cache"
|
||||
cau_run_logged paccache -r --nocolor -k"$CFG_KEEP_OLD" || cau_warn "paccache -r failed"
|
||||
cau_run_logged paccache -ru --nocolor -k0 || cau_warn "paccache -ru failed"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Finding the humans on this machine and running things on their behalf.
|
||||
#
|
||||
# The updater runs as a root system service, but a fair amount of the work
|
||||
# (per-user Flatpak installations, AppImages, desktop notifications) only makes
|
||||
# sense inside a user's own session.
|
||||
|
||||
# Lowest uid considered a human account. Matches /etc/login.defs on Arch.
|
||||
CAU_UID_MIN=1000
|
||||
CAU_UID_MAX=60000
|
||||
|
||||
# cau_human_users
|
||||
# Prints "user uid home" for every non-system account with a real shell.
|
||||
cau_human_users() {
|
||||
local name uid home shell
|
||||
while IFS=: read -r name _ uid _ _ home shell; do
|
||||
(( uid >= CAU_UID_MIN && uid <= CAU_UID_MAX )) || continue
|
||||
case "$shell" in
|
||||
*/nologin|*/false|'') continue ;;
|
||||
esac
|
||||
printf '%s %s %s\n' "$name" "$uid" "$home"
|
||||
done < <(getent passwd)
|
||||
}
|
||||
|
||||
# cau_active_sessions
|
||||
# Prints "user uid session_id" for every seated, graphical, non-closing
|
||||
# session. These are the sessions a notification can actually reach.
|
||||
cau_active_sessions() {
|
||||
local ids id props name uid class type state
|
||||
|
||||
cau_have loginctl || return 0
|
||||
|
||||
ids="$(loginctl list-sessions --no-legend 2>/dev/null | awk '{print $1}')"
|
||||
[[ -n $ids ]] || return 0
|
||||
|
||||
for id in $ids; do
|
||||
props="$(loginctl show-session "$id" \
|
||||
--property=Name --property=User --property=Class \
|
||||
--property=Type --property=State 2>/dev/null)" || continue
|
||||
|
||||
name=''; uid=''; class=''; type=''; state=''
|
||||
while IFS='=' read -r k v; do
|
||||
case "$k" in
|
||||
Name) name="$v" ;;
|
||||
User) uid="$v" ;;
|
||||
Class) class="$v" ;;
|
||||
Type) type="$v" ;;
|
||||
State) state="$v" ;;
|
||||
esac
|
||||
done <<< "$props"
|
||||
|
||||
[[ $class == user ]] || continue
|
||||
[[ $state == active || $state == online ]] || continue
|
||||
case "$type" in
|
||||
wayland|x11|mir) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
[[ -n $name && -n $uid ]] || continue
|
||||
|
||||
printf '%s %s %s\n' "$name" "$uid" "$id"
|
||||
done
|
||||
}
|
||||
|
||||
# cau_active_session_users
|
||||
# Deduplicated "user uid" for everyone with a reachable graphical session.
|
||||
cau_active_session_users() {
|
||||
cau_active_sessions | awk '{print $1, $2}' | sort -u
|
||||
}
|
||||
|
||||
# cau_user_locale <user> <uid>
|
||||
# Best effort at the locale that user's desktop is running in, so a
|
||||
# notification does not arrive in English on a German machine.
|
||||
cau_user_locale() {
|
||||
local user="$1" uid="$2" locale=''
|
||||
|
||||
if [[ -d "/run/user/$uid" ]]; then
|
||||
locale="$(cau_as_user "$user" "$uid" systemctl --user show-environment 2>/dev/null \
|
||||
| sed -n 's/^LANG=//p' | head -n1)"
|
||||
fi
|
||||
|
||||
if [[ -z $locale && -r /etc/locale.conf ]]; then
|
||||
locale="$(sed -n 's/^LANG=//p' /etc/locale.conf | tr -d '"' | head -n1)"
|
||||
fi
|
||||
|
||||
printf '%s\n' "${locale:-C}"
|
||||
}
|
||||
|
||||
# cau_as_user <user> <uid> <command> [args...]
|
||||
# Runs a command as that user with a session-shaped environment. LC_ALL is
|
||||
# explicitly cleared: the service sets LC_ALL=C for parseable tool output, but
|
||||
# anything user-facing should follow the user's own locale.
|
||||
cau_as_user() {
|
||||
local user="$1" uid="$2"
|
||||
shift 2
|
||||
local home
|
||||
home="$(getent passwd "$user" | cut -d: -f6)"
|
||||
|
||||
local -a env_args=(
|
||||
"HOME=${home:-/home/$user}"
|
||||
"USER=$user"
|
||||
"LOGNAME=$user"
|
||||
"XDG_RUNTIME_DIR=/run/user/$uid"
|
||||
)
|
||||
[[ -S "/run/user/$uid/bus" ]] && \
|
||||
env_args+=("DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$uid/bus")
|
||||
|
||||
runuser -u "$user" -- env --unset=LC_ALL "${env_args[@]}" "$@"
|
||||
}
|
||||
Reference in new issue
Block a user