feat: entertainment hardening — notify_send delivery, plugin lib, last-run state, config edit; shared user-timers lib

This commit is contained in:
Your Name
2026-08-13 14:13:06 +00:00
parent d9316ec7f7
commit a4c025d236
20 changed files with 493 additions and 349 deletions
+54 -97
View File
@@ -4,7 +4,6 @@
CONFIG_DIR="$HOME/.config/linux_post_install"
CONFIG_FILE="$CONFIG_DIR/entertainment.env"
USER_SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
TIMER_PREFIX="pos-entertainment"
DEFAULT_INTERVAL="daily"
@@ -14,6 +13,17 @@ declare -F warn >/dev/null || warn() { echo "[!] $*"; }
declare -F ok >/dev/null || ok() { echo " OK $*"; }
declare -F log >/dev/null || log() { echo "[+] $*"; }
# Shared systemd **user** timer machinery (interval→OnCalendar mapping, unit
# pair writer, linger bootstrap) — the same lib the system scheduler uses, so
# the two unit templates never drift apart. Defines USER_SYSTEMD_DIR + ut_*.
source "$(dirname "${BASH_SOURCE[0]}")/../lib/user-timers-lib.sh" 2>/dev/null \
|| source "$(dirname "${BASH_SOURCE[0]}")/user-timers-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/../lib/user-timers-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/user-timers-lib.sh"
# Per-plugin last-run state (rc + timestamp + first output line).
LAST_RUN_DIR="${LAST_RUN_DIR:-$HOME/.local/share/linux_post_install/entertainment/last}"
# ── Config file helpers (file is the source of truth, never sourced) ──
config_value() {
local k="$1" v
@@ -28,6 +38,14 @@ write_config_key() {
val="${val//$'\r'/}"
val="${val%%$'\n'*}"
mkdir -p "$CONFIG_DIR"
if [ "$val" = "-" ]; then
[ -f "$CONFIG_FILE" ] || return 0
tmp="$(mktemp)"
grep -v "^${key}=" "$CONFIG_FILE" >"$tmp" || true
mv "$tmp" "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
return 0
fi
tmp="$(mktemp)"
grep -v "^${key}=" "$CONFIG_FILE" 2>/dev/null >"$tmp" || true
printf '%s="%s"\n' "$key" "$val" >>"$tmp"
@@ -200,96 +218,10 @@ enabled_remove() {
write_config_key ENABLED "$(render_enabled "${out[@]}")"
}
# ── Interval → systemd OnCalendar ─────────────────────────────────
interval_to_oncalendar() {
local i="$1"
case "$i" in
OnCalendar=*) printf '%s' "${i#OnCalendar=}"; return 0 ;;
esac
if [[ "$i" =~ ^([0-9]+)m$ ]]; then
local n="${BASH_REMATCH[1]}"
[ "$n" -ge 1 ] && [ "$n" -le 59 ] || return 1
printf '*:00/%s:00' "$n"; return 0
fi
if [[ "$i" =~ ^([0-9]+)h$ ]]; then
local n="${BASH_REMATCH[1]}"
[ "$n" -ge 1 ] && [ "$n" -le 23 ] || return 1
printf '*-*-* 00/%s:00:00' "$n"; return 0
fi
case "$i" in
hourly) printf '*-*-* *:00:00' ;;
daily) printf '*-*-* 08:00:00' ;;
weekly) printf 'Mon *-*-* 08:00:00' ;;
*) return 1 ;;
esac
return 0
}
interval_label() {
case "$1" in
daily) echo "daily (08:00)" ;;
weekly) echo "weekly (Mon 08:00)" ;;
hourly) echo "hourly" ;;
OnCalendar=*) echo "${1#OnCalendar=}" ;;
*m|*h|*d) echo "every $1" ;;
*) echo "$1" ;;
esac
}
# ── systemd user timers ───────────────────────────────────────────
unit_name() { echo "${TIMER_PREFIX}-${1}"; }
write_units() {
local plugin="$1" oncal="$2"
local base="$USER_SYSTEMD_DIR/$(unit_name "$plugin")"
local runner
runner="$(command -v pos-entertainment-send 2>/dev/null || echo /usr/local/bin/pos-entertainment-send)"
mkdir -p "$USER_SYSTEMD_DIR"
cat >"$base.service" <<EOF
[Unit]
Description=pos entertainment send $plugin
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=$runner $plugin
TimeoutStopSec=5s
[Install]
WantedBy=timers.target
EOF
cat >"$base.timer" <<EOF
[Unit]
Description=Schedule: pos entertainment send $plugin
[Timer]
OnCalendar=$oncal
Persistent=true
[Install]
WantedBy=timers.target
EOF
chmod 644 "$base.service" "$base.timer"
}
ensure_linger() {
local user
user="$(id -un)"
[ "$user" = "root" ] && { warn "running as root — enable linger for your real user: sudo loginctl enable-linger <user>"; return 0; }
command -v loginctl >/dev/null 2>&1 || return 0
if loginctl show-user "$user" 2>/dev/null | grep -q '^Linger=yes'; then
return 0
fi
if sudo -n loginctl enable-linger "$user" 2>/dev/null; then
ok "enabled linger for $user (timers fire without login)"
else
warn "run once so timers fire without login: sudo loginctl enable-linger $user"
fi
}
# Reconcile the auto-trigger schedule with the ENABLED list in the config.
# Backend: systemd user timers (requires a reachable user systemd manager).
# Interval → OnCalendar mapping, unit naming, the unit pair writer and linger
# bootstrap come from lib/user-timers-lib.sh (ut_*). Reconcile the auto-trigger
# schedule with the ENABLED list in the config.
sync_timers() {
if systemctl --user show-environment >/dev/null 2>&1; then
sync_systemd
@@ -312,24 +244,26 @@ sync_systemd() {
warn "plugin '$plugin' not installed — skipping"
continue
fi
if ! oncal="$(interval_to_oncalendar "$interval")"; then
if ! oncal="$(ut_interval_to_oncalendar "$interval")"; then
warn "invalid interval '$interval' for '$plugin' — skipping"
continue
fi
wanted["$plugin"]="$oncal"
done
local wrote=0 t n=0
local runner wrote=0 t n=0
runner="$(command -v pos-entertainment-send 2>/dev/null || echo /usr/local/bin/pos-entertainment-send)"
for plugin in "${!wanted[@]}"; do
write_units "$plugin" "${wanted[$plugin]}"
local base="$USER_SYSTEMD_DIR/$(ut_unit_name "$TIMER_PREFIX" "$plugin")"
ut_write_unit_pair "$base.service" "$base.timer" "pos entertainment send $plugin" "$runner $plugin" "${wanted[$plugin]}"
wrote=1; n=$((n + 1))
done
[ "$wrote" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1 || true
[ "$n" -gt 0 ] && ensure_linger
[ "$n" -gt 0 ] && ut_ensure_linger
for plugin in "${!wanted[@]}"; do
t="$(unit_name "$plugin").timer"
t="$(ut_unit_name "$TIMER_PREFIX" "$plugin").timer"
if systemctl --user is-enabled "$t" >/dev/null 2>&1; then
systemctl --user restart "$t" >/dev/null 2>&1 || true
else
@@ -343,8 +277,8 @@ sync_systemd() {
[ -f "$f" ] || continue
p="${f##*/}"; p="${p#${TIMER_PREFIX}-}"; p="${p%.timer}"
if [ -z "${wanted[$p]:-}" ]; then
systemctl --user disable --now "$(unit_name "$p").timer" >/dev/null 2>&1 || true
rm -f "$USER_SYSTEMD_DIR/$(unit_name "$p").timer" "$USER_SYSTEMD_DIR/$(unit_name "$p").service"
systemctl --user disable --now "$(ut_unit_name "$TIMER_PREFIX" "$p").timer" >/dev/null 2>&1 || true
rm -f "$USER_SYSTEMD_DIR/$(ut_unit_name "$TIMER_PREFIX" "$p").timer" "$USER_SYSTEMD_DIR/$(ut_unit_name "$TIMER_PREFIX" "$p").service"
log "removed timer for '$p'"
removed=1
fi
@@ -352,3 +286,26 @@ sync_systemd() {
[ "$removed" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1 || true
}
# ── Per-plugin last-run state ────────────────────────────────────
# Written by 'pos entertainment send' on every non---print run so 'status' can
# show whether a scheduled run succeeded. State dir is per-user, not tracked.
save_last_run() { # $1 = plugin, $2 = rc, $3 = message (first line)
local f="$LAST_RUN_DIR/$1"
mkdir -p "$LAST_RUN_DIR"
{
printf 'rc=%s\n' "$2"
printf 'ts=%s\n' "$(date +%s)"
printf 'msg=%s\n' "$3"
} >"$f"
chmod 600 "$f"
}
last_run_str() { # $1 = plugin → "rc=N (MM-DD HH:MM)" or "never"
local f="$LAST_RUN_DIR/$1" rc ts
[ -f "$f" ] || { echo "never"; return 0; }
rc="$(sed -n 's/^rc=//p' "$f" | tail -1)"
ts="$(sed -n 's/^ts=//p' "$f" | tail -1)"
[ -n "$rc" ] || { echo "never"; return 0; }
printf 'rc=%s (%s)' "$rc" "$(date -d "@$ts" '+%m-%d %H:%M' 2>/dev/null || echo '?')"
}
+67
View File
@@ -0,0 +1,67 @@
# lib/entertainment-plugin-lib.sh — message-safe helpers for entertainment
# plugins. Plugins MAY source this (unlike lib/common.sh, whose log/warn/ok
# chatter would leak into the sent message). Contract: this lib NEVER writes to
# stdout — helpers print errors to stderr and exit non-zero, so stdout stays the
# message. Defines only plugin_* names, so it can't collide with common.sh or a
# plugin's own helpers.
#
# Source pattern (works from the repo and from /usr/local/bin after install):
# source "$(dirname "${BASH_SOURCE[0]}")/../lib/entertainment-plugin-lib.sh" 2>/dev/null \
# || source "$(dirname "${BASH_SOURCE[0]}")/entertainment-plugin-lib.sh" 2>/dev/null \
# || source "$(dirname "$0")/../lib/entertainment-plugin-lib.sh" 2>/dev/null \
# || source "$(dirname "$0")/entertainment-plugin-lib.sh"
# Config file is the source of truth — read it like the pos tools do (never source it).
plugin_config_file="${PLUGIN_CONFIG_FILE:-$HOME/.config/linux_post_install/entertainment.env}"
plugin_err() { echo "ERROR: $*" >&2; exit 1; }
# Load KEY=VALUE pairs from entertainment.env into the environment.
# Env vars already set win (env precedence); quoted values are stripped;
# comments and blank lines skipped.
plugin_load_config() {
[ -f "$plugin_config_file" ] || return 0
local k v
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in
\#*) continue ;;
esac
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
fi
done < <(grep -E '^[A-Z_]+=' "$plugin_config_file" || true)
}
# Fail unless the command is available.
plugin_have() {
command -v "$1" >/dev/null 2>&1 || plugin_err "$1 not found — install it (see DOC/PREINSTALL.md or preinstall.sh PACKAGES)"
}
# Fail unless the config key is set (after plugin_load_config).
plugin_require() {
[ -n "${!1:-}" ] || plugin_err "$1 not set — add it to $plugin_config_file"
}
# Fetch a URL as JSON with curl, retrying twice on transient failures, then
# extract the given jq key (optional). Everything is silent on stdout; errors
# go to stderr and exit non-zero.
plugin_http_json() {
local key="" url="" json
local -a headers=()
while [ $# -gt 0 ]; do
case "$1" in
--key) key="$2"; shift 2 ;;
--header|-H) headers+=(-H "$2"); shift 2 ;;
*) url="$1"; shift ;;
esac
done
[ -n "$url" ] || plugin_err "plugin_http_json: no URL given"
json="$(curl -fsS --max-time 20 --retry 2 --retry-delay 2 "${headers[@]}" "$url")" || \
plugin_err "Failed to fetch $url"
if [ -n "$key" ]; then
json="$(jq -r "$key" <<<"$json")" || plugin_err "Failed to parse '$key' from $url"
fi
printf '%s' "$json"
}
+27 -97
View File
@@ -41,6 +41,14 @@ declare -F warn >/dev/null || warn() { echo "[!] $*"; }
declare -F ok >/dev/null || ok() { echo " OK $*"; }
declare -F log >/dev/null || log() { echo "[+] $*"; }
# Shared systemd **user** timer machinery (interval→OnCalendar mapping, unit
# pair writer, linger bootstrap) — the same lib the entertainment module uses,
# so the two unit templates never drift apart. Defines USER_SYSTEMD_DIR + ut_*.
source "$(dirname "${BASH_SOURCE[0]}")/../lib/user-timers-lib.sh" 2>/dev/null \
|| source "$(dirname "${BASH_SOURCE[0]}")/user-timers-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/../lib/user-timers-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/user-timers-lib.sh"
# ── Job discovery / naming ───────────────────────────────────────
sched_valid_name() {
[[ "$1" =~ ^[a-z0-9][a-z0-9_-]*$ ]]
@@ -341,92 +349,9 @@ sched_run_job() { # $1 = job name
[ "${DRY_RUN:-0}" -ne 1 ] && sched_save_run "$1" "$JOB_OUT" "$JOB_RC"
return 0
}
# ── Interval → systemd OnCalendar (same list as entertainment) ──
sched_interval_to_oncalendar() {
local i="$1"
case "$i" in
OnCalendar=*) printf '%s' "${i#OnCalendar=}"; return 0 ;;
esac
if [[ "$i" =~ ^([0-9]+)m$ ]]; then
local n="${BASH_REMATCH[1]}"
[ "$n" -ge 1 ] && [ "$n" -le 59 ] || return 1
printf '*:00/%s:00' "$n"; return 0
fi
if [[ "$i" =~ ^([0-9]+)h$ ]]; then
local n="${BASH_REMATCH[1]}"
[ "$n" -ge 1 ] && [ "$n" -le 23 ] || return 1
printf '*-*-* 00/%s:00:00' "$n"; return 0
fi
case "$i" in
hourly) printf '*-*-* *:00:00' ;;
daily) printf '*-*-* 08:00:00' ;;
weekly) printf 'Mon *-*-* 08:00:00' ;;
*) return 1 ;;
esac
return 0
}
sched_interval_label() {
case "$1" in
daily) echo "daily (08:00)" ;;
weekly) echo "weekly (Mon 08:00)" ;;
hourly) echo "hourly" ;;
OnCalendar=*) echo "${1#OnCalendar=}" ;;
*m|*h) echo "every $1" ;;
*) echo "$1" ;;
esac
}
# ── systemd user timers (one pair per job) ──────────────────────
sched_unit() { printf '%s-%s\n' "$SCHED_PREFIX" "$1"; }
sched_ensure_linger() {
local user
user="$(id -un)"
[ "$user" = "root" ] && { warn "running as root — enable linger for your real user: sudo loginctl enable-linger <user>"; return 0; }
command -v loginctl >/dev/null 2>&1 || return 0
if loginctl show-user "$user" 2>/dev/null | grep -q '^Linger=yes'; then
return 0
fi
if sudo -n loginctl enable-linger "$user" 2>/dev/null; then
ok "enabled linger for $user (timers fire without login)"
else
warn "run once so timers fire without login: sudo loginctl enable-linger $user"
fi
}
sched_write_units() { # $1 = job name, $2 = OnCalendar
local name="$1" oncal="$2"
local base="$USER_SYSTEMD_DIR/$(sched_unit "$name")"
mkdir -p "$USER_SYSTEMD_DIR"
cat >"$base.service" <<EOF
[Unit]
Description=pos system schedule — run job $name
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=$SCHED_RUNNER run $name
TimeoutStopSec=5s
[Install]
WantedBy=timers.target
EOF
cat >"$base.timer" <<EOF
[Unit]
Description=Schedule: pos system schedule — $name
[Timer]
OnCalendar=$oncal
Persistent=true
[Install]
WantedBy=timers.target
EOF
chmod 644 "$base.service" "$base.timer"
}
# Interval → OnCalendar mapping, unit naming, the unit pair writer and linger
# bootstrap come from lib/user-timers-lib.sh (ut_*).
sched_job_active() { # $1 = job name — valid AND not disabled
sched_read_job "$1" >/dev/null 2>&1 || return 1
[ "$JOB_ENABLED" = "false" ] && return 1
@@ -444,27 +369,32 @@ sched_sync() {
continue
fi
[ "$JOB_ENABLED" = "false" ] && continue # disabled: orphan cleanup drops its timer
if ! oncal="$(sched_interval_to_oncalendar "$JOB_INTERVAL")"; then
if ! oncal="$(ut_interval_to_oncalendar "$JOB_INTERVAL")"; then
warn "invalid interval '$JOB_INTERVAL' for '$name' — skipping"
continue
fi
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) write $(sched_unit "$name").timer (OnCalendar=$oncal)"
log "(dry-run) write $(ut_unit_name "$SCHED_PREFIX" "$name").timer (OnCalendar=$oncal)"
else
sched_write_units "$name" "$oncal"
ut_write_unit_pair \
"$USER_SYSTEMD_DIR/$(ut_unit_name "$SCHED_PREFIX" "$name").service" \
"$USER_SYSTEMD_DIR/$(ut_unit_name "$SCHED_PREFIX" "$name").timer" \
"pos system schedule — run job $name" \
"$SCHED_RUNNER run $name" \
"$oncal"
fi
wrote=1; n=$((n + 1))
done
[ "${DRY_RUN:-0}" -eq 1 ] && return 0
[ "$wrote" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1 || true
[ "$n" -gt 0 ] && sched_ensure_linger
[ "$n" -gt 0 ] && ut_ensure_linger
for name in "${jobs[@]}"; do
if ! sched_read_job "$name" >/dev/null 2>&1; then
continue
fi
[ "$JOB_ENABLED" = "false" ] && continue
sched_interval_to_oncalendar "$JOB_INTERVAL" >/dev/null 2>&1 || continue
local t="$(sched_unit "$name").timer"
ut_interval_to_oncalendar "$JOB_INTERVAL" >/dev/null 2>&1 || continue
local t="$(ut_unit_name "$SCHED_PREFIX" "$name").timer"
if systemctl --user is-enabled "$t" >/dev/null 2>&1; then
systemctl --user restart "$t" >/dev/null 2>&1 || true
else
@@ -483,8 +413,8 @@ sched_remove_orphans() {
[ -f "$f" ] || continue
p="${f##*/}"; p="${p#${SCHED_PREFIX}-}"; p="${p%.timer}"
if ! sched_job_active "$p"; then
systemctl --user disable --now "$(sched_unit "$p").timer" >/dev/null 2>&1 || true
rm -f "$USER_SYSTEMD_DIR/$(sched_unit "$p").timer" "$USER_SYSTEMD_DIR/$(sched_unit "$p").service"
systemctl --user disable --now "$(ut_unit_name "$SCHED_PREFIX" "$p").timer" >/dev/null 2>&1 || true
rm -f "$USER_SYSTEMD_DIR/$(ut_unit_name "$SCHED_PREFIX" "$p").timer" "$USER_SYSTEMD_DIR/$(ut_unit_name "$SCHED_PREFIX" "$p").service"
log "removed timer for '$p'"
removed=1
fi
@@ -693,8 +623,8 @@ sched_status() {
local st="enabled" oncal next=""
[ "$JOB_ENABLED" = "false" ] && st="disabled"
if [ "$st" = "enabled" ]; then
if oncal="$(sched_interval_to_oncalendar "$JOB_INTERVAL")"; then
next="$(systemctl --user list-timers "$(sched_unit "$name").timer" --no-legend 2>/dev/null | awk '{print $1, $2}' | head -1)"
if oncal="$(ut_interval_to_oncalendar "$JOB_INTERVAL")"; then
next="$(systemctl --user list-timers "$(ut_unit_name "$SCHED_PREFIX" "$name").timer" --no-legend 2>/dev/null | awk '{print $1, $2}' | head -1)"
else
oncal="(invalid interval: $JOB_INTERVAL)"
fi
@@ -743,7 +673,7 @@ sched_editor_add() {
[ -f "$SCHEDULE_DIR/$name.env" ] && { warn "job '$name' already exists — use edit"; return; }
read -rp "interval [5m..59m | 1h..23h | hourly daily weekly | OnCalendar=…] (blank = $SCHED_DEFAULT_INTERVAL): " interval
interval="${interval:-$SCHED_DEFAULT_INTERVAL}"
sched_interval_to_oncalendar "$interval" >/dev/null 2>&1 || warn "interval '$interval' not recognized — saved anyway, 'enable' will skip it"
ut_interval_to_oncalendar "$interval" >/dev/null 2>&1 || warn "interval '$interval' not recognized — saved anyway, 'enable' will skip it"
read -rp "notify [always|onchange|onerror|threshold|never] (blank = onchange): " notify
notify="${notify:-onchange}"
case "$notify" in always|onchange|onerror|threshold|never) ;; *) warn "unknown policy '$notify' — job will run silently" ;; esac
@@ -775,7 +705,7 @@ sched_editor_edit() {
local interval notify msg rule cmd
read -rp "interval (blank keeps, current: $JOB_INTERVAL): " interval
interval="${interval:-$JOB_INTERVAL}"
sched_interval_to_oncalendar "$interval" >/dev/null 2>&1 || warn "interval '$interval' not recognized — saved anyway, 'enable' will skip it"
ut_interval_to_oncalendar "$interval" >/dev/null 2>&1 || warn "interval '$interval' not recognized — saved anyway, 'enable' will skip it"
read -rp "notify (blank keeps, current: $JOB_NOTIFY): " notify
notify="${notify:-$JOB_NOTIFY}"
read -rp "message (blank keeps, current: ${JOB_MSG:-<none>}): " msg
+112
View File
@@ -0,0 +1,112 @@
# lib/user-timers-lib.sh — shared systemd **user** timer machinery for the pos
# tools that schedule one-shot jobs: the entertainment module and the system
# scheduler. Sourced by lib/entertainment-lib.sh and lib/scheduler-lib.sh AFTER
# lib/common.sh. Defines only ut_* helpers so it never collides with either lib.
#
# Both libs previously re-implemented the same interval→OnCalendar mapping, unit
# pair writer and linger bootstrap (the two copies drifted — e.g. the
# TimeoutStopSec fix had to be applied to both). This is the single source for:
# ut_interval_to_oncalendar ut_interval_label ut_unit_name
# ut_write_unit_pair ut_ensure_linger
# common.sh helpers (guarded so the lib is safe if common.sh wasn't loaded)
declare -F err >/dev/null || err() { echo "ERROR: $*" >&2; exit 1; }
declare -F warn >/dev/null || warn() { echo "[!] $*"; }
declare -F ok >/dev/null || ok() { echo " OK $*"; }
declare -F log >/dev/null || log() { echo "[+] $*"; }
USER_SYSTEMD_DIR="${USER_SYSTEMD_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user}"
# ── Interval → systemd OnCalendar ─────────────────────────────────
# Named + numeric intervals shared by both schedulers, or a raw OnCalendar=…
# spec passed through unchanged. Returns non-zero for invalid values.
ut_interval_to_oncalendar() {
local i="$1"
case "$i" in
OnCalendar=*) printf '%s' "${i#OnCalendar=}"; return 0 ;;
esac
if [[ "$i" =~ ^([0-9]+)m$ ]]; then
local n="${BASH_REMATCH[1]}"
[ "$n" -ge 1 ] && [ "$n" -le 59 ] || return 1
printf '*:00/%s:00' "$n"; return 0
fi
if [[ "$i" =~ ^([0-9]+)h$ ]]; then
local n="${BASH_REMATCH[1]}"
[ "$n" -ge 1 ] && [ "$n" -le 23 ] || return 1
printf '*-*-* 00/%s:00:00' "$n"; return 0
fi
case "$i" in
hourly) printf '*-*-* *:00:00' ;;
daily) printf '*-*-* 08:00:00' ;;
weekly) printf 'Mon *-*-* 08:00:00' ;;
*) return 1 ;;
esac
return 0
}
ut_interval_label() {
case "$1" in
daily) echo "daily (08:00)" ;;
weekly) echo "weekly (Mon 08:00)" ;;
hourly) echo "hourly" ;;
OnCalendar=*) echo "${1#OnCalendar=}" ;;
*m|*h|*d) echo "every $1" ;;
*) echo "$1" ;;
esac
}
# ── Unit naming / writer ──────────────────────────────────────────
ut_unit_name() { # $1 = prefix (e.g. pos-entertainment), $2 = name → prefix-name
printf '%s-%s\n' "$1" "$2"
}
# Write one service/timer pair (oneshot, network-online, Persistent).
# Usage: ut_write_unit_pair <service-file> <timer-file> <description> <execstart> <oncal>
ut_write_unit_pair() {
local svc="$1" timer="$2" desc="$3" execstart="$4" oncal="$5"
mkdir -p "$USER_SYSTEMD_DIR"
cat >"$svc" <<EOF
[Unit]
Description=$desc
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=$execstart
TimeoutStopSec=5s
[Install]
WantedBy=timers.target
EOF
cat >"$timer" <<EOF
[Unit]
Description=Schedule: $desc
[Timer]
OnCalendar=$oncal
Persistent=true
[Install]
WantedBy=timers.target
EOF
chmod 644 "$svc" "$timer"
}
# ── Linger bootstrap ──────────────────────────────────────────────
# Timers only fire without a login when the user has linger enabled; try once,
# warn with the exact command otherwise (never fails the caller).
ut_ensure_linger() {
local user
user="$(id -un)"
[ "$user" = "root" ] && { warn "running as root — enable linger for your real user: sudo loginctl enable-linger <user>"; return 0; }
command -v loginctl >/dev/null 2>&1 || return 0
if loginctl show-user "$user" 2>/dev/null | grep -q '^Linger=yes'; then
return 0
fi
if sudo -n loginctl enable-linger "$user" 2>/dev/null; then
ok "enabled linger for $user (timers fire without login)"
else
warn "run once so timers fire without login: sudo loginctl enable-linger $user"
fi
}