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:
Felitendo committed 2026-08-08 02:27:04 +02:00
1 parent 327da1dae3
commit ceb024d7be
28 files changed
+3856 -1

No files matched your search

+225
View File
@@ -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'
}
+206
View File
@@ -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 ))
}
+111
View File
@@ -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 '')"
}
+105
View File
@@ -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
View File
@@ -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
}
+122
View File
@@ -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
}
+76
View File
@@ -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
}
+158
View File
@@ -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
}
+71
View File
@@ -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
}
+184
View File
@@ -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
}
+109
View File
@@ -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[@]}" "$@"
}