Arrow-key navigation took 435 ms per keypress, which reads as the whole console reloading on every press - because it effectively was. The cost was forks: each frame ran a command substitution per label for the value, the translation and the rendered state, 54 subshells for eighteen rows. The frame is now assembled in memory and written once. Translations, the split specs and the terminfo clear string are resolved before the loop; values are re-read only after something actually changes, not on cursor movement. Helpers on that path assign to a variable instead of printing, since printing is what forced the substitution. Measured on the same machine: 435 ms -> 7.5 ms per keypress.
291 lines
9.8 KiB
Bash
291 lines
9.8 KiB
Bash
# 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_into <locale> <msgid>
|
|
# Plain lookup with the result in CAU_MSG_RESULT and no printf formatting.
|
|
# For callers that redraw many labels per keypress, where wrapping cau_msg in a
|
|
# command substitution would cost a fork per label.
|
|
CAU_MSG_RESULT=''
|
|
|
|
cau_msg_into() {
|
|
local locale="$1" msgid="$2" cachekey
|
|
cachekey="${locale}"$'\x1f'"${msgid}"
|
|
|
|
if [[ -n ${CAU_MSG_CACHE[$cachekey]+set} ]]; then
|
|
CAU_MSG_RESULT="${CAU_MSG_CACHE[$cachekey]}"
|
|
return 0
|
|
fi
|
|
|
|
CAU_MSG_RESULT="$(LC_ALL="$locale" LANGUAGE="${locale%%.*}" gettext -- "$msgid" 2>/dev/null)"
|
|
[[ -n $CAU_MSG_RESULT ]] || CAU_MSG_RESULT="$msgid"
|
|
CAU_MSG_CACHE[$cachekey]="$CAU_MSG_RESULT"
|
|
return 0
|
|
}
|
|
|
|
# Translations are memoized. Every gettext lookup is a fork, and the settings
|
|
# screen redraws forty-odd labels per keypress; without this the redraw takes
|
|
# long enough that a keystroke arriving during it is lost when the terminal
|
|
# switches back to single-character mode.
|
|
declare -A CAU_MSG_CACHE=()
|
|
|
|
# cau_msg_in <locale> <msgid> [printf args...]
|
|
cau_msg_in() {
|
|
local locale="$1" msgid="$2" translated cachekey
|
|
shift 2
|
|
|
|
cachekey="${locale}"$'\x1f'"${msgid}"
|
|
if [[ -n ${CAU_MSG_CACHE[$cachekey]+set} ]]; then
|
|
translated="${CAU_MSG_CACHE[$cachekey]}"
|
|
else
|
|
translated="$(LC_ALL="$locale" LANGUAGE="${locale%%.*}" gettext -- "$msgid" 2>/dev/null)"
|
|
[[ -n $translated ]] || translated="$msgid"
|
|
CAU_MSG_CACHE[$cachekey]="$translated"
|
|
fi
|
|
|
|
# With no arguments the message is plain text, not a format string. Feeding
|
|
# it to printf anyway turns any literal percent sign in it - "Battery (%)",
|
|
# "100 % done" - into an invalid conversion, and that is a trap every
|
|
# translator would eventually walk into.
|
|
if (( $# == 0 )); then
|
|
printf '%s' "$translated"
|
|
return
|
|
fi
|
|
|
|
# shellcheck disable=SC2059 # the format string is the translated message
|
|
printf -- "$translated" "$@"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Output and logging
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Is a person watching? Decided once, here, while stdout is still whatever the
|
|
# process was started with. Testing `-t 1` at the point of use is unreliable:
|
|
# any function called through $(...) or <(...) sees a pipe on stdout and would
|
|
# conclude nobody is there.
|
|
CAU_INTERACTIVE=''
|
|
[[ -t 1 ]] && CAU_INTERACTIVE=1
|
|
|
|
if [[ -n $CAU_INTERACTIVE && -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, capturing its combined output in the run log. Returns the
|
|
# command's exit status.
|
|
#
|
|
# When a person is watching - `cachy-auto-update run` from a terminal - the
|
|
# output is shown as well. Building an AUR package or pulling a few hundred
|
|
# megabytes of Flatpak can take minutes, and silence for that long is
|
|
# indistinguishable from a hang.
|
|
cau_run_logged() {
|
|
if [[ -n ${CAU_LOG_OPEN:-} ]]; then
|
|
if [[ -n $CAU_INTERACTIVE ]]; then
|
|
"$@" 2>&1 | tee -a "$CAU_RUNLOG"
|
|
return "${PIPESTATUS[0]}"
|
|
fi
|
|
"$@" >> "$CAU_RUNLOG" 2>&1
|
|
else
|
|
"$@" >&2
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Terminal helpers for the CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Set by cau_bad and cau_note. The menu redraws immediately after an action,
|
|
# which would wipe the screen; this marks that something was printed that the
|
|
# user still has to read, so only those cases wait for a keypress. A plain
|
|
# success needs no acknowledgement - the status block at the top of the menu
|
|
# already shows the new state.
|
|
CAU_UI_NEEDS_ACK=''
|
|
|
|
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() { CAU_UI_NEEDS_ACK=1; printf '%s✘%s %s\n' "$CAU_C_RED" "$CAU_C_RESET" "$*" >&2; }
|
|
cau_note() { CAU_UI_NEEDS_ACK=1; 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'
|
|
}
|