From 7b4a08f4825dccf3aff8e5095de45010ca69980e Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 10 Jul 2026 21:57:05 +0200 Subject: [PATCH] feat(digital-wellbeing): time-boxed shutdown overrides + drift-proof i3blocks deploy 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) Claude-Session: https://claude.ai/code/session_01NmwYH5RfThZGkVD2jE61Da --- ...own-override-i3blocks-sync-2026-07-10.json | 16 + ...own-override-i3blocks-sync-2026-07-10.json | 54 ++++ .../setup_midnight_shutdown.sh | 303 +++++++++++++++++- .../i3blocks/shutdown_countdown.sh | 78 ++++- .../i3-configuration/install.sh | 25 +- .../tests/test_i3blocks_efficiency.sh | 30 +- .../tests/test_i3blocks_persist_common.sh | 6 +- 7 files changed, 484 insertions(+), 28 deletions(-) create mode 100644 docs/superpowers/contracts/shutdown-override-i3blocks-sync-2026-07-10.json create mode 100644 docs/superpowers/evidence/shutdown-override-i3blocks-sync-2026-07-10.json diff --git a/docs/superpowers/contracts/shutdown-override-i3blocks-sync-2026-07-10.json b/docs/superpowers/contracts/shutdown-override-i3blocks-sync-2026-07-10.json new file mode 100644 index 00000000..e0179dab --- /dev/null +++ b/docs/superpowers/contracts/shutdown-override-i3blocks-sync-2026-07-10.json @@ -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" +} diff --git a/docs/superpowers/evidence/shutdown-override-i3blocks-sync-2026-07-10.json b/docs/superpowers/evidence/shutdown-override-i3blocks-sync-2026-07-10.json new file mode 100644 index 00000000..f5546ac7 --- /dev/null +++ b/docs/superpowers/evidence/shutdown-override-i3blocks-sync-2026-07-10.json @@ -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 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." + ] +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh index da613129..dd423ea0 100755 --- a/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh @@ -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 " >&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 '' '' " >&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 '' '' Register a time-boxed exception" + echo " list Show active/upcoming overrides" + echo " remove 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 diff --git a/linux_configuration/scripts/periodic_background/i3-configuration/i3blocks/shutdown_countdown.sh b/linux_configuration/scripts/periodic_background/i3-configuration/i3blocks/shutdown_countdown.sh index fd86fa69..36c71213 100755 --- a/linux_configuration/scripts/periodic_background/i3-configuration/i3blocks/shutdown_countdown.sh +++ b/linux_configuration/scripts/periodic_background/i3-configuration/i3blocks/shutdown_countdown.sh @@ -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" diff --git a/linux_configuration/scripts/periodic_background/i3-configuration/install.sh b/linux_configuration/scripts/periodic_background/i3-configuration/install.sh index 3a7ed488..cd54b1f1 100755 --- a/linux_configuration/scripts/periodic_background/i3-configuration/install.sh +++ b/linux_configuration/scripts/periodic_background/i3-configuration/install.sh @@ -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 diff --git a/linux_configuration/tests/test_i3blocks_efficiency.sh b/linux_configuration/tests/test_i3blocks_efficiency.sh index dedb22f0..d204dc66 100755 --- a/linux_configuration/tests/test_i3blocks_efficiency.sh +++ b/linux_configuration/tests/test_i3blocks_efficiency.sh @@ -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' diff --git a/linux_configuration/tests/test_i3blocks_persist_common.sh b/linux_configuration/tests/test_i3blocks_persist_common.sh index f129253c..d791499b 100755 --- a/linux_configuration/tests/test_i3blocks_persist_common.sh +++ b/linux_configuration/tests/test_i3blocks_persist_common.sh @@ -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" <