feat(digital-wellbeing): time-boxed shutdown overrides + drift-proof i3blocks deploy
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run

Add a self-expiring override system so specific late-night live events
(2026 World Cup matches) can be watched without the auto-shutdown killing
the PC, without weakening the permanent schedule ratchet:

- shutdown-override-manager.sh (add/list/remove): add is friction-gated
  (typed confirmation phrase + 30s delay, max 12h, max 3 windows) and
  writes absolute-epoch windows to /etc/shutdown-schedule-overrides.conf.
- day-specific-shutdown-check.sh now skips the shutdown decision inside an
  active window (pruning expired entries) and gained a DRY_RUN guard around
  rtcwake/poweroff/hibernate for safe testing.

i3blocks shutdown_countdown.sh: show the exact next-shutdown clock time
(not a relative countdown), show the overridden end time in green when an
override is active or will rescue the upcoming shutdown, and make the
skip-dates check fork-free (builtin read loop) so the hot path spawns no
external processes.

install.sh: deploy i3blocks by symlinking each repo file into
~/.config/i3blocks instead of cp -r (the source of silent drift), cd to
the script dir so it runs from anywhere, and chmod +x only *.sh so the
config file stays 0644.

Repair the previously-dead i3blocks regression suite (stale path), update
the shutdown assertion to the new absolute-time output, add hermetic
override coverage, and keep the <=1-exec fork-count guarantee.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmwYH5RfThZGkVD2jE61Da
This commit is contained in:
Krzysztof Rudnicki 2026-07-10 21:57:05 +02:00
parent 343d38eaf8
commit 7b4a08f482
7 changed files with 484 additions and 28 deletions

View File

@ -0,0 +1,16 @@
{
"title": "World Cup shutdown overrides + drift-proof i3blocks deploy",
"objective": "Allow time-boxed, self-expiring exceptions to the digital-wellbeing auto-shutdown so specific late-night live events can be watched on the PC, show the exact next-shutdown (or overridden) clock time in the i3bar, and make the i3blocks deployment symlink-based so running scripts can never silently drift from the repo. Success = a friction-gated override suspends the shutdown only within its window and expires on its own, the i3bar reflects the real next-shutdown time, and the reconciled scripts are the repo's single source of truth with the regression suite green.",
"acceptance_criteria": [
"shutdown-override-manager.sh add requires a typed confirmation phrase plus a 30s delay, enforces max duration and window count, and writes an absolute-epoch window that day-specific-shutdown-check.sh honors (verified live via DRY_RUN and journal logs).",
"shutdown_countdown.sh shows the exact clock time of the next shutdown, the overridden end time in green when an override is active or will rescue the upcoming shutdown, and spawns zero external processes in its hot path (strace: 1 execve).",
"install.sh symlinks every i3blocks file into ~/.config/i3blocks (idempotent) and leaves the non-script config at 0644; all 18 tracked files verified as symlinks after the user's live run.",
"The i3blocks regression suite (test_i3blocks_efficiency.sh + test_i3blocks_persist_common.sh) runs from its correct path and passes, including new absolute-time and override assertions and the fork-count guarantee."
],
"out_of_scope": [
"Weakening the permanent shutdown ratchet or lowering MORNING_END_HOUR's hard floor (overrides are strictly time-boxed and expire).",
"Reconciling the three other pre-existing drifted config trees (phone_focus_mode, meta, .pre-commit-config) unrelated to this change.",
"Any change to the i3 (window manager) config beyond what install.sh already did."
],
"verifier": "bash linux_configuration/tests/test_i3blocks_efficiency.sh (exit 0), shellcheck on the changed shell scripts, and the ai-evidence / pre-commit gate"
}

View File

@ -0,0 +1,54 @@
{
"intent": "Let the PC survive the normal digital-wellbeing auto-shutdown during specific live-event windows (2026 World Cup matches) via self-expiring, friction-gated overrides; surface the exact next-shutdown clock time (or the overridden time) in the i3bar; and make the i3blocks deploy drift-proof so the running scripts can never silently diverge from the repo.",
"scope": [
"linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh",
"linux_configuration/scripts/periodic_background/i3-configuration/i3blocks/shutdown_countdown.sh",
"linux_configuration/scripts/periodic_background/i3-configuration/install.sh",
"linux_configuration/tests/test_i3blocks_efficiency.sh",
"linux_configuration/tests/test_i3blocks_persist_common.sh",
"Non-goal: does not weaken the permanent shutdown ratchet or lower MORNING_END_HOUR's hard floor; overrides are time-boxed and expire automatically."
],
"changes": [
"Added a time-boxed override system: shutdown-override-manager.sh (add/list/remove) writes absolute-epoch windows to /etc/shutdown-schedule-overrides.conf; adding requires an exact typed confirmation phrase plus a 30s delay. The generated day-specific-shutdown-check.sh now skips the shutdown decision inside an active window (pruning expired entries) and gained a DRY_RUN guard around rtcwake/poweroff/hibernate for safe testing.",
"Rewrote the i3blocks shutdown_countdown.sh to show the exact clock time of the next shutdown (e.g. '21:00') instead of a relative countdown, to show the overridden end time in green when an override is active or will rescue the upcoming shutdown, and made the skip-dates check fork-free (bash builtin read loop replacing grep -qxF) so the hot path spawns zero external processes.",
"Changed install.sh to deploy i3blocks by symlinking each repo file into ~/.config/i3blocks (was cp -r, the source of silent drift), added cd-to-script-dir so it runs from anywhere, and restricted chmod +x to *.sh so the non-executable config file is left at 0644. Reconciled all 18 live files (17 scripts + config) to symlinks.",
"Repaired the previously-dead i3blocks regression suite: fixed the stale i3-configuration path in both test files, updated the shutdown assertion to the new absolute-time output, added hermetic override coverage, and re-verified the fork-count guarantee (<=1 exec)."
],
"verification": [
{
"command": "sudo DRY_RUN=1 /usr/local/bin/day-specific-shutdown-check.sh (with a short test override active)",
"result": "pass",
"evidence": "journalctl showed 'Active override in effect (reason: ...) - skipping shutdown check' and the script exited without evaluating the day-of-week branch; outside the window it logged the normal skip/shutdown decision unchanged."
},
{
"command": "NOW_EPOCH=... bash ~/.config/i3blocks/shutdown_countdown.sh across normal/active-override/gap/skip-date cases",
"result": "pass",
"evidence": "Normal time -> '⏻ 21:00'; inside override -> '▶ 00:30 (override)' green; gap between windows -> '⏻ SHUTDOWN'; matching skip-date -> empty output exit 0. Matches expected enforcement."
},
{
"command": "strace -f -e trace=execve bash ~/.config/i3blocks/shutdown_countdown.sh",
"result": "pass",
"evidence": "Exactly 1 execve (the bash interpreter); no grep/date forks in the hot path after the builtin-read rewrite."
},
{
"command": "bash tests/test_i3blocks_efficiency.sh",
"result": "pass",
"evidence": "Exit code 0 - 'All i3blocks efficiency regression tests passed.' including the new absolute-time and override assertions and the fork-count check."
},
{
"command": "shellcheck on shutdown_countdown.sh and install.sh (-s sh); bash -n on setup_midnight_shutdown.sh",
"result": "pass",
"evidence": "All lint-clean, zero warnings; setup script syntax OK. Live install.sh run by the user completed through 'i3-msg reload' -> [{\"success\":true}] and 18/18 files verified as symlinks."
}
],
"risks": [
"Overrides intentionally suspend the wellbeing shutdown for their window; a mistakenly-long window keeps the PC up. Mitigated by the 12h max duration, 3-window cap, typed-confirmation + 30s delay on add, and automatic expiry.",
"The override and schedule config files must stay world-readable (0644) because the i3blocks countdown runs as the regular user; a future 0600 regression would blank the block (the script now degrades on unreadable files rather than crashing).",
"Symlink deploy assumes the repo stays at its current path; moving the repo breaks the ~/.config/i3blocks symlinks until install.sh is re-run."
],
"rollback": [
"i3blocks: git checkout the four repo files, then re-run install.sh (or cp the previous scripts into ~/.config/i3blocks) to restore prior deploy; symlinks are replaced idempotently.",
"Overrides: sudo shutdown-override-manager.sh remove <n> cancels a window immediately, or truncate /etc/shutdown-schedule-overrides.conf; the check script prunes expired entries on its own.",
"After rollback, run bash tests/test_i3blocks_efficiency.sh and confirm the i3bar block still renders to validate the restored state."
]
}

View File

@ -324,6 +324,14 @@ show_current_status() {
echo " - Canonical copy that auto-restores on modification"
echo " - Path watcher service"
echo ""
echo "Active Overrides:"
if command -v /usr/local/bin/shutdown-override-manager.sh >/dev/null 2>&1; then
/usr/local/bin/shutdown-override-manager.sh list | sed 's/^/ /'
else
echo " (override manager not installed)"
fi
echo ""
}
# Function to create/update shutdown schedule config file (shared with
@ -819,6 +827,49 @@ create_shutdown_check_script() {
# Reads shutdown windows from /etc/shutdown-schedule.conf
CONFIG_FILE="/etc/shutdown-schedule.conf"
OVERRIDES_FILE="/etc/shutdown-schedule-overrides.conf"
# Time-boxed exceptions (e.g. watching a live event that runs past the normal
# shutdown window) are registered via shutdown-override-manager.sh, which
# appends "start_epoch|end_epoch|created_epoch|reason" lines to OVERRIDES_FILE.
# Entries are absolute-epoch-bound so they expire on their own; this function
# prunes stale lines and returns success (skip shutdown) if now falls inside
# any remaining window.
check_active_override() {
[[ -f "$OVERRIDES_FILE" ]] || return 1
local now
now=$(printf '%(%s)T' -1)
local kept=""
local active=false
local active_reason=""
local start_epoch end_epoch _created reason
while IFS='|' read -r start_epoch end_epoch _created reason; do
[[ -n "$start_epoch" ]] || continue
if [[ $end_epoch -lt $now ]]; then
continue # expired, drop it
fi
kept+="${start_epoch}|${end_epoch}|${_created}|${reason}"$'\n'
if [[ $now -ge $start_epoch ]] && [[ $now -le $end_epoch ]]; then
active=true
active_reason="$reason"
fi
done <"$OVERRIDES_FILE"
printf '%s' "$kept" >"$OVERRIDES_FILE"
if [[ $active == true ]]; then
logger -t day-specific-shutdown "Active override in effect (reason: ${active_reason}) - skipping shutdown check"
return 0
fi
return 1
}
if check_active_override; then
exit 0
fi
# Load config
if [[ ! -f "$CONFIG_FILE" ]]; then
@ -910,12 +961,20 @@ if [[ $should_shutdown == true ]]; then
1|5|6|7)
wake_epoch=$(( $(printf '%(%s)T' -1) + 8 * 3600 ))
logger -t day-specific-shutdown "Tomorrow is alarm day (dow=$tomorrow_dow) — hibernating, RTC wake at epoch $wake_epoch"
/usr/bin/sudo /usr/sbin/rtcwake -m no -t "$wake_epoch"
/usr/bin/systemctl hibernate -i
if [[ "${DRY_RUN:-}" == "1" ]]; then
logger -t day-specific-shutdown "DRY_RUN: would run rtcwake -m no -t $wake_epoch then systemctl hibernate -i"
else
/usr/bin/sudo /usr/sbin/rtcwake -m no -t "$wake_epoch"
/usr/bin/systemctl hibernate -i
fi
;;
*)
logger -t day-specific-shutdown "Tomorrow is not an alarm day — powering off normally"
/usr/bin/systemctl poweroff -i
if [[ "${DRY_RUN:-}" == "1" ]]; then
logger -t day-specific-shutdown "DRY_RUN: would run systemctl poweroff -i"
else
/usr/bin/systemctl poweroff -i
fi
;;
esac
else
@ -928,6 +987,243 @@ EOF
echo "✓ Created smart shutdown check script: $check_script"
}
# Function to create the time-boxed override manager (event exceptions like
# watching a live match that runs past the normal shutdown window). Overrides
# are absolute-epoch-bound so they expire on their own; the friction (typed
# confirmation + delay) lives entirely in this CLI, matching the existing
# unlock script's philosophy for the permanent schedule ratchet.
create_override_manager_script() {
echo ""
echo "9. Creating Shutdown Override Manager..."
echo "========================================"
local overrides_file="/etc/shutdown-schedule-overrides.conf"
local log_file="/var/log/shutdown-schedule-overrides.log"
local manager_script="/usr/local/bin/shutdown-override-manager.sh"
touch "$overrides_file"
# World-readable (like shutdown-schedule.conf): the i3blocks countdown
# script runs as the regular user and needs to read active overrides to
# display them. Only root can write to it (via this manager script).
chmod 644 "$overrides_file"
touch "$log_file"
chmod 600 "$log_file"
cat >"$manager_script" <<'EOF'
#!/bin/bash
# Shutdown Override Manager
# Registers time-boxed exceptions to the day-specific-shutdown schedule, e.g.
# to watch a live event that runs past the normal shutdown window. Entries are
# absolute-epoch-bound and expire on their own - there is nothing to re-lock.
set -euo pipefail
OVERRIDES_FILE="/etc/shutdown-schedule-overrides.conf"
LOG_FILE="/var/log/shutdown-schedule-overrides.log"
MAX_OVERRIDE_HOURS=12
MAX_ACTIVE_OVERRIDES=3
MIN_REASON_LEN=10
CONFIRM_PHRASE="I am deliberately bypassing my shutdown protection"
DELAY_SECONDS=30
log() {
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" >&2
}
require_root() {
if [[ $EUID -ne 0 ]]; then
echo "This command requires sudo." >&2
exit 1
fi
}
now_epoch() {
printf '%(%s)T' -1
}
prune_and_read() {
# Prints kept (non-expired) lines; overwrites OVERRIDES_FILE with them.
local now kept=""
now=$(now_epoch)
if [[ -f "$OVERRIDES_FILE" ]]; then
local start_epoch end_epoch created reason
while IFS='|' read -r start_epoch end_epoch created reason; do
[[ -n "$start_epoch" ]] || continue
[[ "$end_epoch" -lt "$now" ]] && continue
kept+="${start_epoch}|${end_epoch}|${created}|${reason}"$'\n'
done <"$OVERRIDES_FILE"
fi
printf '%s' "$kept" >"$OVERRIDES_FILE"
printf '%s' "$kept"
}
cmd_list() {
local kept
kept="$(prune_and_read)"
if [[ -z "$kept" ]]; then
echo "No active or upcoming overrides."
return 0
fi
echo "Active/upcoming overrides:"
local i=1
local start_epoch end_epoch created reason
while IFS='|' read -r start_epoch end_epoch created reason; do
[[ -n "$start_epoch" ]] || continue
printf ' [%d] %s -> %s (reason: %s)\n' \
"$i" \
"$(date -d "@$start_epoch" '+%Y-%m-%d %H:%M')" \
"$(date -d "@$end_epoch" '+%Y-%m-%d %H:%M')" \
"$reason"
i=$((i + 1))
done <<<"$kept"
}
cmd_remove() {
require_root
local target="${1:-}"
[[ -n "$target" ]] || { echo "Usage: $0 remove <line-number-from-list>" >&2; exit 1; }
local kept
kept="$(prune_and_read)"
if [[ -z "$kept" ]]; then
echo "No overrides to remove." >&2
exit 1
fi
local new_content="" i=1 start_epoch end_epoch created reason removed=false
while IFS='|' read -r start_epoch end_epoch created reason; do
[[ -n "$start_epoch" ]] || continue
if [[ "$i" == "$target" ]]; then
removed=true
else
new_content+="${start_epoch}|${end_epoch}|${created}|${reason}"$'\n'
fi
i=$((i + 1))
done <<<"$kept"
if [[ "$removed" != true ]]; then
echo "No override at index $target" >&2
exit 1
fi
printf '%s' "$new_content" >"$OVERRIDES_FILE"
log "Removed override index $target (by $(logname 2>/dev/null || echo unknown))"
echo "✓ Removed override $target"
}
cmd_add() {
require_root
local start_str="${1:-}" end_str="${2:-}"
local reason="${*:3}"
if [[ -z "$start_str" ]] || [[ -z "$end_str" ]] || [[ -z "$reason" ]]; then
echo "Usage: $0 add '<start datetime>' '<end datetime>' <reason>" >&2
echo "Example: $0 add '2026-07-10 21:30' '2026-07-11 00:30' 'World Cup match'" >&2
exit 1
fi
if [[ "${#reason}" -lt "$MIN_REASON_LEN" ]]; then
echo "Reason must be at least $MIN_REASON_LEN characters." >&2
exit 1
fi
local start_epoch end_epoch
start_epoch=$(date -d "$start_str" +%s) || { echo "Could not parse start datetime: $start_str" >&2; exit 1; }
end_epoch=$(date -d "$end_str" +%s) || { echo "Could not parse end datetime: $end_str" >&2; exit 1; }
if [[ "$end_epoch" -le "$start_epoch" ]]; then
echo "End must be after start." >&2
exit 1
fi
local duration_hours=$(( (end_epoch - start_epoch) / 3600 ))
if [[ "$duration_hours" -gt "$MAX_OVERRIDE_HOURS" ]]; then
echo "Override duration (${duration_hours}h) exceeds the max of ${MAX_OVERRIDE_HOURS}h." >&2
exit 1
fi
local kept active_count
kept="$(prune_and_read)"
active_count=$(grep -c . <<<"$kept" 2>/dev/null || true)
active_count="${active_count:-0}"
if [[ "$active_count" -ge "$MAX_ACTIVE_OVERRIDES" ]]; then
echo "Already at the max of $MAX_ACTIVE_OVERRIDES active/upcoming overrides. Remove one first." >&2
cmd_list
exit 1
fi
echo ""
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║ SHUTDOWN SCHEDULE OVERRIDE REQUEST ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
echo ""
echo "This suspends the shutdown enforcement you built for yourself during:"
echo " $(date -d "@$start_epoch" '+%Y-%m-%d %H:%M') -> $(date -d "@$end_epoch" '+%Y-%m-%d %H:%M') (${duration_hours}h)"
echo " Reason: $reason"
echo ""
echo "Current overrides:"
cmd_list
echo ""
echo "To confirm this is a deliberate, one-off exception (not just wanting to"
echo "stay up), type the following phrase exactly:"
echo ""
echo " $CONFIRM_PHRASE"
echo ""
read -r -p "> " typed_phrase
if [[ "$typed_phrase" != "$CONFIRM_PHRASE" ]]; then
echo "Phrase did not match. Aborting." >&2
log "ABORTED: confirmation phrase mismatch for window $start_str -> $end_str"
exit 1
fi
echo ""
echo "Waiting $DELAY_SECONDS seconds before committing (Ctrl+C to cancel)..."
local i
for ((i = DELAY_SECONDS; i > 0; i--)); do
printf "\r Waiting: %2d seconds remaining..." "$i"
sleep 1
done
echo ""
local created
created=$(now_epoch)
printf '%s|%s|%s|%s\n' "$start_epoch" "$end_epoch" "$created" "$reason" >>"$OVERRIDES_FILE"
log "ADDED override by $(logname 2>/dev/null || echo unknown) from TTY $(tty 2>/dev/null || echo unknown): $start_str -> $end_str (reason: $reason)"
echo "✓ Override registered: $(date -d "@$start_epoch" '+%Y-%m-%d %H:%M') -> $(date -d "@$end_epoch" '+%Y-%m-%d %H:%M')"
}
case "${1:-}" in
add)
shift
cmd_add "$@"
;;
list)
cmd_list
;;
remove)
shift
cmd_remove "$@"
;;
*)
echo "Shutdown Override Manager"
echo "Usage: $0 {add|list|remove}"
echo ""
echo " add '<start>' '<end>' <reason> Register a time-boxed exception"
echo " list Show active/upcoming overrides"
echo " remove <index> Cancel an override (no friction)"
echo ""
cmd_list
;;
esac
EOF
chmod +x "$manager_script"
echo "✓ Created override manager: $manager_script"
echo "✓ Overrides file: $overrides_file (root-only)"
echo "✓ Override log: $log_file"
}
# Function to enable the timer
enable_timer() {
echo ""
@ -1405,6 +1701,7 @@ enable_midnight_shutdown() {
create_shutdown_timer
create_management_script
create_shutdown_check_script
create_override_manager_script
# Enable and start timer
enable_timer

View File

@ -1,9 +1,13 @@
#!/bin/bash
# Shutdown countdown status script for i3blocks.
# Shows the exact absolute time of the next enforced shutdown, or the
# overridden time if a shutdown-override-manager.sh window rescues it.
set -euo pipefail
SHUTDOWN_CONFIG=${SHUTDOWN_CONFIG:-/etc/shutdown-schedule.conf}
SKIP_DATES_FILE=${SKIP_DATES_FILE:-/etc/shutdown-skip-dates}
OVERRIDES_FILE=${OVERRIDES_FILE:-/etc/shutdown-schedule-overrides.conf}
# Function to show error state in i3blocks and exit
show_error() {
@ -57,12 +61,25 @@ get_now_epoch() {
}
now_epoch=$(get_now_epoch)
printf -v today_date '%(%Y-%m-%d)T' "$now_epoch"
# Fork-free whole-line match against the skip-dates file (bash builtin read,
# no `grep` process spawned every tick). Equivalent to `grep -qxF`.
if [[ -r $SKIP_DATES_FILE ]]; then
while IFS= read -r skip_date || [[ -n $skip_date ]]; do
if [[ $skip_date == "$today_date" ]]; then
exit 0
fi
done < "$SKIP_DATES_FILE"
fi
printf -v current_hour '%(%H)T' "$now_epoch"
printf -v current_minute '%(%M)T' "$now_epoch"
printf -v current_second '%(%S)T' "$now_epoch"
printf -v day_of_week '%(%u)T' "$now_epoch"
current_time_minutes=$((10#$current_hour * 60 + 10#$current_minute))
morning_end_minutes=$((10#$morning_end_hour * 60))
midnight_epoch=$((now_epoch - (10#$current_hour * 3600 + 10#$current_minute * 60 + 10#$current_second)))
if [[ $day_of_week -ge 1 ]] && [[ $day_of_week -le 3 ]]; then
shutdown_hour=$MON_WED_HOUR
@ -71,7 +88,41 @@ else
fi
shutdown_time_minutes=$((shutdown_hour * 60))
shutdown_epoch_today=$((midnight_epoch + shutdown_hour * 3600))
# Prints "start_epoch|end_epoch|reason" for the first registered override
# (shutdown-override-manager.sh) whose window covers the given epoch, empty
# otherwise. Builtin `read` only - no forks in this hot path.
find_override_covering() {
local target_epoch=$1
[[ -f $OVERRIDES_FILE ]] || return 1
local start_epoch end_epoch _created reason
while IFS='|' read -r start_epoch end_epoch _created reason; do
[[ -n $start_epoch ]] || continue
if [[ $target_epoch -ge $start_epoch ]] && [[ $target_epoch -le $end_epoch ]]; then
printf '%s|%s|%s\n' "$start_epoch" "$end_epoch" "$reason"
return 0
fi
done <"$OVERRIDES_FILE"
return 1
}
format_hhmm() {
printf '%(%H:%M)T' "$1"
}
# Case 1: an override is active right now - always takes priority, whether
# or not we are inside the normal blocked window.
if override_match=$(find_override_covering "$now_epoch"); then
IFS='|' read -r _ override_end _ <<<"$override_match"
echo "▶ $(format_hhmm "$override_end") (override)"
echo "▶ $(format_hhmm "$override_end")"
echo "#50FA7B"
exit 0
fi
# Case 2: currently inside the normal blocked window with no rescuing
# override registered - shutdown is due now.
if [[ $current_time_minutes -ge $shutdown_time_minutes ]] || [[ $current_time_minutes -le $morning_end_minutes ]]; then
echo "⏻ SHUTDOWN"
echo "⏻"
@ -79,30 +130,29 @@ if [[ $current_time_minutes -ge $shutdown_time_minutes ]] || [[ $current_time_mi
exit 0
fi
minutes_until_shutdown=$((shutdown_time_minutes - current_time_minutes))
hours=$((minutes_until_shutdown / 60))
minutes=$((minutes_until_shutdown % 60))
if [[ $hours -gt 0 ]]; then
time_str="${hours}h ${minutes}m"
else
time_str="${minutes}m"
# Case 3: normal usable time. Show the exact hour of the next shutdown,
# unless a registered override starts before it and extends past it - in
# which case show the rescued (overridden) time instead, proactively.
if override_match=$(find_override_covering "$shutdown_epoch_today"); then
IFS='|' read -r _ override_end _ <<<"$override_match"
echo "▶ $(format_hhmm "$override_end") (override)"
echo "▶ $(format_hhmm "$override_end")"
echo "#50FA7B"
exit 0
fi
minutes_until_shutdown=$((shutdown_time_minutes - current_time_minutes))
if [[ $minutes_until_shutdown -le 30 ]]; then
color="#FF5555"
icon="⏻"
elif [[ $minutes_until_shutdown -le 60 ]]; then
color="#FFB86C"
icon="⏻"
elif [[ $minutes_until_shutdown -le 120 ]]; then
color="#F1FA8C"
icon="⏻"
else
color="#6272A4"
icon="⏻"
fi
echo "$icon $time_str"
echo "$icon"
echo "⏻ $(format_hhmm "$shutdown_epoch_today")"
echo "⏻"
echo "$color"

View File

@ -1,5 +1,10 @@
#!/bin/sh
# Resolve the directory this script lives in and work from there, so every
# relative path below (i3blocks/, i3/) resolves no matter where it is invoked.
SCRIPT_DIR=$(unset CDPATH; cd -- "$(dirname -- "$0")" && pwd)
cd "$SCRIPT_DIR" || exit 1
# Function to detect if the system is Ubuntu
is_ubuntu() {
[ -f /etc/os-release ] && grep -qi 'ubuntu' /etc/os-release
@ -46,9 +51,23 @@ fi
# Set font size based on screen resolution
font_size=$(set_font_size)
# Make all scripts in i3blocks executable
find i3blocks -type f -exec chmod +x {} \;
cp -r i3blocks ~/.config/
# Make the i3blocks shell scripts executable (only *.sh — the `config` file
# and any runtime artifacts must stay non-executable).
find i3blocks -type f -name '*.sh' -exec chmod +x {} \;
# Deploy i3blocks by SYMLINKING each repo file into ~/.config/i3blocks rather
# than copying it. A plain `cp -r` here was the source of silent drift: edits
# to a repo script never reached the running copy (and live tweaks never made
# it back to the repo). Symlinks make the repo the single source of truth, so
# editing a repo script IS editing the deployed one — drift becomes impossible.
mkdir -p "$HOME/.config/i3blocks"
for src in "$SCRIPT_DIR"/i3blocks/*; do
[ -f "$src" ] || continue
ln -sfn "$src" "$HOME/.config/i3blocks/$(basename "$src")"
done
# i3 config is copied (not symlinked) because it needs a per-machine font-size
# mutation applied below; symlinking would write that change back into the repo.
cp -r i3 ~/.config/
sed -i "s/font pango:System San Francisco Display, FontAwesome [0-9]*/font pango:System San Francisco Display, FontAwesome $font_size/" ~/.config/i3/config
i3-msg reload

View File

@ -5,7 +5,7 @@ set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd)
I3BLOCKS_DIR="$REPO_DIR/i3-configuration/i3blocks"
I3BLOCKS_DIR="$REPO_DIR/scripts/periodic_background/i3-configuration/i3blocks"
CONFIG_FILE="$I3BLOCKS_DIR/config"
printf 'Running persist_common helper regression checks...\n'
@ -310,18 +310,38 @@ MON_WED_HOUR=23
THU_SUN_HOUR=21
EOF
# Isolate from the machine's real override file so these assertions are hermetic.
shutdown_overrides_empty="$TMP_DIR/shutdown-overrides-empty.conf"
: > "$shutdown_overrides_empty"
# 2026-05-01 is a Friday -> THU_SUN_HOUR=21 applies; at 19:30 the next shutdown
# is 21:00. The block shows the exact clock time of the shutdown, not a
# relative countdown.
shutdown_countdown_epoch=$(epoch_utc '2026-05-01 19:30:00')
shutdown_countdown_output=$(TZ=UTC NOW_EPOCH="$shutdown_countdown_epoch" SHUTDOWN_CONFIG="$shutdown_config_file" PATH="$BIN_DIR:$PATH" bash "$I3BLOCKS_DIR/shutdown_countdown.sh")
assert_equals '⏻ 1h 30m' "$(printf '%s\n' "$shutdown_countdown_output" | sed -n '1p')" \
'shutdown countdown should show the time remaining until shutdown'
shutdown_countdown_output=$(TZ=UTC NOW_EPOCH="$shutdown_countdown_epoch" SHUTDOWN_CONFIG="$shutdown_config_file" OVERRIDES_FILE="$shutdown_overrides_empty" PATH="$BIN_DIR:$PATH" bash "$I3BLOCKS_DIR/shutdown_countdown.sh")
assert_equals '⏻ 21:00' "$(printf '%s\n' "$shutdown_countdown_output" | sed -n '1p')" \
'shutdown countdown should show the exact clock time of the next shutdown'
assert_equals '#F1FA8C' "$(printf '%s\n' "$shutdown_countdown_output" | sed -n '3p')" \
'shutdown countdown should show yellow for two hours or less remaining'
assert_le "$(count_execs "$I3BLOCKS_DIR/shutdown_countdown.sh")" 1 \
'shutdown countdown should avoid date helpers in the hot path'
shutdown_window_epoch=$(epoch_utc '2026-05-01 21:15:00')
shutdown_window_output=$(TZ=UTC NOW_EPOCH="$shutdown_window_epoch" SHUTDOWN_CONFIG="$shutdown_config_file" PATH="$BIN_DIR:$PATH" bash "$I3BLOCKS_DIR/shutdown_countdown.sh")
shutdown_window_output=$(TZ=UTC NOW_EPOCH="$shutdown_window_epoch" SHUTDOWN_CONFIG="$shutdown_config_file" OVERRIDES_FILE="$shutdown_overrides_empty" PATH="$BIN_DIR:$PATH" bash "$I3BLOCKS_DIR/shutdown_countdown.sh")
assert_equals '⏻ SHUTDOWN' "$(printf '%s\n' "$shutdown_window_output" | sed -n '1p')" \
'shutdown countdown should show SHUTDOWN inside the blocked window'
# An active override (shutdown-override-manager.sh) rescues the shutdown: the
# block should show the overridden end time in green instead of a countdown or
# a SHUTDOWN warning. Window 19:00-23:00 covers the 19:30 sample time.
shutdown_overrides_active="$TMP_DIR/shutdown-overrides-active.conf"
override_start=$(epoch_utc '2026-05-01 19:00:00')
override_end=$(epoch_utc '2026-05-01 23:00:00')
printf '%s|%s|%s|%s\n' "$override_start" "$override_end" "$override_start" 'regression test override' > "$shutdown_overrides_active"
shutdown_override_output=$(TZ=UTC NOW_EPOCH="$shutdown_countdown_epoch" SHUTDOWN_CONFIG="$shutdown_config_file" OVERRIDES_FILE="$shutdown_overrides_active" PATH="$BIN_DIR:$PATH" bash "$I3BLOCKS_DIR/shutdown_countdown.sh")
assert_equals '▶ 23:00 (override)' "$(printf '%s\n' "$shutdown_override_output" | sed -n '1p')" \
'shutdown countdown should show the overridden end time when an override is active'
assert_equals '#50FA7B' "$(printf '%s\n' "$shutdown_override_output" | sed -n '3p')" \
'shutdown countdown should show green while an override is active'
printf 'All i3blocks efficiency regression tests passed.\n'

View File

@ -5,7 +5,7 @@ set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd)
HELPER="$REPO_DIR/i3-configuration/i3blocks/persist_common.sh"
HELPER="$REPO_DIR/scripts/periodic_background/i3-configuration/i3blocks/persist_common.sh"
TMP_DIR=$(mktemp -d)
cleanup() {
@ -13,7 +13,7 @@ cleanup() {
}
trap cleanup EXIT
# shellcheck source=linux_configuration/i3-configuration/i3blocks/persist_common.sh
# shellcheck source=linux_configuration/scripts/periodic_background/i3-configuration/i3blocks/persist_common.sh
source "$HELPER"
fail() {
@ -136,7 +136,7 @@ cat >"$fork_probe" <<EOF
#!/bin/bash
set -euo pipefail
# shellcheck source=linux_configuration/i3-configuration/i3blocks/persist_common.sh
# shellcheck source=linux_configuration/scripts/periodic_background/i3-configuration/i3blocks/persist_common.sh
source "$HELPER"
I3BLOCKS_LAST_TS=()