fix(linux_configuration): harden polling/runtime scripts and add tests

- music_parallelism.sh + thesis_work_tracker.sh: tighter state-output and
  error paths; expanded regression tests.
- hosts-file-monitor.sh + shutdown-timer-monitor.sh: harden against partial
  failures, matching new test branches.
- i3blocks persist_common.sh helper improved (consumed by activitywatch /
  warp status blocks).
- setup_midnight_shutdown.sh + thesis_work_status.sh: state parsing tuned.
- pacman_blocked_keywords.txt: drop one obsolete entry.
- New test_thesis_work_status.sh regression script.

All six bash regression tests pass.
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-05-14 19:55:42 +02:00
parent 65d25ac46a
commit 11c792ef3a
18 changed files with 974 additions and 136 deletions

View File

@ -0,0 +1,16 @@
{
"title": "Linux configuration polling/runtime hardening 2026-05-14",
"objective": "Continue hardening the i3blocks and digital-wellbeing shell scripts against fork-storm/polling anti-patterns and runtime bugs while adding regression tests; preserve fail-closed behavior across hosts/shutdown monitors.",
"acceptance_criteria": [
"All modified shell scripts pass their bash regression tests under linux_configuration/tests/.",
"music_parallelism.sh, thesis_work_tracker.sh, shutdown timer/hosts file monitors, and persist_common.sh keep deterministic state output even under partial failures.",
"New test_thesis_work_status.sh regression script covers the state-parsing helper.",
"No new noqa/shellcheck suppressions introduced."
],
"out_of_scope": [
"Changing i3 keybindings or systemd unit topology.",
"Modifying screen_locker or steam_backlog_enforcer in this commit.",
"Refactoring pacman blocklist policy beyond the single keyword adjustment."
],
"verifier": "bash linux_configuration/tests/test_*.sh (all six modified/new tests) plus pre-commit run on staged files"
}

View File

@ -0,0 +1,70 @@
{
"intent": "Harden i3blocks and digital-wellbeing polling scripts and their tests; cover thesis_work_status.sh with a fresh regression test.",
"scope": [
"linux_configuration/i3-configuration/i3blocks/activitywatch_status.sh",
"linux_configuration/i3-configuration/i3blocks/persist_common.sh",
"linux_configuration/i3-configuration/i3blocks/warp_status.sh",
"linux_configuration/scripts/digital_wellbeing/music_parallelism.sh",
"linux_configuration/scripts/digital_wellbeing/setup_midnight_shutdown.sh",
"linux_configuration/scripts/digital_wellbeing/thesis_work_status.sh",
"linux_configuration/scripts/digital_wellbeing/thesis_work_tracker.sh",
"linux_configuration/scripts/digital_wellbeing/pacman/pacman_blocked_keywords.txt",
"linux_configuration/scripts/system-maintenance/bin/hosts-file-monitor.sh",
"linux_configuration/scripts/system-maintenance/bin/shutdown-timer-monitor.sh",
"linux_configuration/tests/test_hosts_file_monitor.sh",
"linux_configuration/tests/test_i3blocks_persist_common.sh",
"linux_configuration/tests/test_music_parallelism.sh",
"linux_configuration/tests/test_shutdown_timer_monitor.sh",
"linux_configuration/tests/test_thesis_work_tracker.sh",
"linux_configuration/tests/test_thesis_work_status.sh (new)",
"Non-goal: changing systemd unit topology or i3 keybindings"
],
"changes": [
"Tightened state-output and error paths in music_parallelism.sh and thesis_work_tracker.sh; expanded their regression tests.",
"Hardened hosts-file-monitor.sh and shutdown-timer-monitor.sh against partial failures; added matching test branches.",
"Improved persist_common.sh helper used by activitywatch_status.sh and warp_status.sh.",
"Tuned setup_midnight_shutdown.sh and thesis_work_status.sh state parsing.",
"Dropped one obsolete entry from pacman_blocked_keywords.txt.",
"Added new test_thesis_work_status.sh regression script."
],
"verification": [
{
"command": "bash linux_configuration/tests/test_hosts_file_monitor.sh",
"result": "pass",
"evidence": "hosts-file-monitor.sh regression checks passed."
},
{
"command": "bash linux_configuration/tests/test_i3blocks_persist_common.sh",
"result": "pass",
"evidence": "persist_common helper regression tests passed."
},
{
"command": "bash linux_configuration/tests/test_music_parallelism.sh",
"result": "pass",
"evidence": "music_parallelism.sh regression checks passed."
},
{
"command": "bash linux_configuration/tests/test_shutdown_timer_monitor.sh",
"result": "pass",
"evidence": "shutdown-timer-monitor.sh regression checks passed."
},
{
"command": "bash linux_configuration/tests/test_thesis_work_tracker.sh",
"result": "pass",
"evidence": "thesis_work_tracker.sh regression checks passed."
},
{
"command": "bash linux_configuration/tests/test_thesis_work_status.sh",
"result": "pass",
"evidence": "thesis_work_status.sh regression checks passed."
}
],
"risks": [
"i3blocks scripts run on every status tick; a regression in persist_common.sh could fork-storm — mitigated by regression tests and prior efficient-polling skill review.",
"Hosts/shutdown monitors run as root in production; permission-denied paths are only exercised in unit tests as non-root."
],
"rollback": [
"git revert this commit; tests will revert with the implementation.",
"Re-run bash linux_configuration/tests/test_*.sh after rollback to confirm baseline still green."
]
}

View File

@ -67,7 +67,7 @@ if is_persist_mode; then
# Intentionally calm heartbeat in persist mode: process-table event streams can
# be extremely noisy and cause unnecessary churn.
while true; do
sleep "$HEARTBEAT_INTERVAL_S"
i3blocks_wait_seconds "$HEARTBEAT_INTERVAL_S"
emit
done
fi

View File

@ -53,3 +53,15 @@ i3blocks_update_if_changed_key() {
I3BLOCKS_LAST_STATE[$key]=$new_state
return 0
}
# Wait for a number of seconds without forking an external `sleep` process.
# Uses bash builtin read timeout. Set I3BLOCKS_TEST_SKIP_WAIT=1 to bypass in tests.
i3blocks_wait_seconds() {
local timeout_s=$1
if [[ ${I3BLOCKS_TEST_SKIP_WAIT:-0} -eq 1 ]]; then
return 0
fi
IFS= read -r -t "$timeout_s" || true
}

View File

@ -64,7 +64,7 @@ if is_persist_mode; then
fi
if is_persist_mode; then
while true; do
sleep "$WARP_POLL_INTERVAL_S"
i3blocks_wait_seconds "$WARP_POLL_INTERVAL_S"
current_status=$(read_status)
emit_if_changed "$current_status"
done

View File

@ -30,6 +30,15 @@ CHECK_INTERVAL=15
FAST_CHECK_INTERVAL=5
IDLE_CHECK_INTERVAL=30
ENFORCEMENT_COOLDOWN=20
PROC_ROOT="${PROC_ROOT:-/proc}"
MUSIC_PROCESS_NAMES=(
"youtube-music"
"spotify"
"tidal"
"deezer"
"amazon music"
)
# Override focus apps with extended list for this script
FOCUS_APPS_WINDOWS=(
@ -80,33 +89,70 @@ MUSIC_SERVICES=(
"pandora.com"
)
build_regex_pattern() {
local -n items=$1
local pattern
printf -v pattern '%s|' "${items[@]}"
printf '%s\n' "${pattern%|}"
}
MUSIC_SERVICES_PATTERN=$(build_regex_pattern MUSIC_SERVICES)
readonly MUSIC_SERVICES_PATTERN
readonly MUSIC_WINDOWS_PATTERN='YouTube Music|music\.youtube\.com|music\.apple\.com|soundcloud\.com|pandora\.com|deezer\.com|tidal\.com'
readonly ACTIVE_NO_MUSIC_INTERVAL=15
readonly ACTIVE_AFTER_KILL_INTERVAL=5
readonly IDLE_CHECK_INTERVAL=30
MUSIC_FOUND_PROCESS=0
MUSIC_FOUND_WINDOW=0
wait_seconds() {
local timeout_s=$1
local start_ts end_ts elapsed_s remaining_s
if [[ -n ${MUSIC_PARALLELISM_TEST_WAIT_LOG:-} ]]; then
printf '%s\n' "$timeout_s" >> "$MUSIC_PARALLELISM_TEST_WAIT_LOG"
if [[ ${MUSIC_PARALLELISM_TEST_EXIT_AFTER_WAIT:-0} -eq 1 ]]; then
exit 99
fi
return 0
fi
printf -v start_ts '%(%s)T' -1
IFS= read -r -t "$timeout_s" || true
printf -v end_ts '%(%s)T' -1
elapsed_s=$((end_ts - start_ts))
if (( elapsed_s < timeout_s )); then
remaining_s=$((timeout_s - elapsed_s))
sleep "$remaining_s"
fi
}
contains_music_process() {
local comm_file comm_lower token_lower
for comm_file in "$PROC_ROOT"/[0-9]*/comm; do
[[ -r $comm_file ]] || continue
read -r comm_lower < "$comm_file" || continue
comm_lower=${comm_lower,,}
for token_lower in "${MUSIC_PROCESS_NAMES[@]}"; do
if [[ $comm_lower == *"${token_lower,,}"* ]]; then
return 0
fi
done
done
return 1
}
# Check if any music service is running and return its details (OPTIMIZED: batch pgrep calls)
find_music_services() {
local found_services=()
MUSIC_FOUND_PROCESS=0
MUSIC_FOUND_WINDOW=0
# Check processes (single fork, no per-PID helpers)
if pgrep -i -f "$MUSIC_SERVICES_PATTERN" &> /dev/null; then
# Check processes using /proc (fork-free)
if contains_music_process; then
MUSIC_FOUND_PROCESS=1
found_services+=("music process")
fi
# Check windows (use optimized is_focus_app_running logic: single xdotool regex call)
if command -v xdotool &> /dev/null && [[ ${#MUSIC_SERVICES[@]} -gt 0 ]]; then
if xdotool search --name "$MUSIC_WINDOWS_PATTERN" &> /dev/null 2>&1; then
MUSIC_FOUND_WINDOW=1
found_services+=("music service (window)")
fi
fi
@ -120,12 +166,19 @@ find_music_services() {
# Kill music services
kill_music_services() {
local use_cached_detection="${1:-0}"
local killed=false
local process_pattern='youtube-music|spotify|tidal|deezer|Amazon Music|amazon music'
local window_pattern='YouTube Music|music\.youtube\.com|music\.apple\.com|soundcloud\.com|pandora\.com|deezer\.com|tidal\.com'
local should_check_windows=1
local should_check_processes=1
if [[ $use_cached_detection -eq 1 ]]; then
should_check_windows=$MUSIC_FOUND_WINDOW
should_check_processes=$MUSIC_FOUND_PROCESS
fi
# Close browser tabs for web-based music services via one xdotool search
if command -v xdotool &> /dev/null; then
if [[ $should_check_windows -eq 1 ]] && command -v xdotool &> /dev/null; then
local windows wid
windows=$(xdotool search --name "$window_pattern" 2> /dev/null || true)
for wid in $windows; do
@ -135,10 +188,28 @@ kill_music_services() {
done
fi
# Kill app processes with one regex-based pkill
if pgrep -i -f "$process_pattern" &> /dev/null; then
pkill -9 -i -f "$process_pattern" 2> /dev/null || true
killed=true
# Kill app processes with /proc scan + builtin kill (fork-free in hot path)
if [[ $should_check_processes -eq 1 ]]; then
local comm_file pid comm_lower token_lower
for comm_file in "$PROC_ROOT"/[0-9]*/comm; do
[[ -r $comm_file ]] || continue
read -r comm_lower < "$comm_file" || continue
comm_lower=${comm_lower,,}
pid=${comm_file#"$PROC_ROOT"/}
pid=${pid%%/*}
for token_lower in "${MUSIC_PROCESS_NAMES[@]}"; do
if [[ $comm_lower == *"${token_lower,,}"* ]]; then
if [[ $PROC_ROOT != "/proc" ]]; then
# Test mode (fake proc tree): mark as killed without signaling host PIDs.
killed=true
elif kill -9 "$pid" 2> /dev/null; then
killed=true
fi
break
fi
done
done
fi
if $killed; then
@ -179,7 +250,7 @@ instant_monitor_loop() {
current_ts=$(get_timestamp)
if (( current_ts >= next_enforcement_ts )); then
if find_music_services > /dev/null 2>&1; then
if kill_music_services; then
if kill_music_services 1; then
notify_user "$focus_app"
log_message "INSTANT KILL: Music services terminated"
sleep_interval="$ACTIVE_AFTER_KILL_INTERVAL"
@ -196,7 +267,7 @@ instant_monitor_loop() {
sleep_interval="$IDLE_CHECK_INTERVAL"
fi
sleep "$sleep_interval"
wait_seconds "$sleep_interval"
done
}
@ -219,13 +290,13 @@ monitor_loop() {
log_message "Active music services: $music_services"
# Kill the music services
if kill_music_services; then
if kill_music_services 1; then
notify_user "$focus_app"
fi
fi
fi
sleep "$CHECK_INTERVAL"
wait_seconds "$CHECK_INTERVAL"
done
}
@ -249,15 +320,10 @@ show_status() {
fi
fi
# Check processes (OPTIMIZED: single pgrep call with combined regex)
if [[ ${#FOCUS_APPS_PROCESSES[@]} -gt 0 ]]; then
local proc_pattern
printf -v proc_pattern '%s|' "${FOCUS_APPS_PROCESSES[@]}"
proc_pattern="${proc_pattern%|}" # strip trailing |
if pgrep -f "$proc_pattern" &> /dev/null; then
echo " ✓ Focus process running"
focus_running=true
fi
# Check processes using shared /proc-based helper (fork-free)
if is_focus_app_running > /dev/null 2>&1; then
echo " ✓ Focus process running"
focus_running=true
fi
if ! $focus_running; then

View File

@ -14,7 +14,6 @@ palemoon
iceweasel
abrowser
cliqz
brave
freetube
seamonkey
min-browser

View File

@ -13,8 +13,8 @@ source "$SCRIPT_DIR/../lib/common.sh"
# Schedule constants (single source of truth for this script)
# These values are written to /etc/shutdown-schedule.conf during setup
SCHEDULE_MON_WED_HOUR=22
SCHEDULE_THU_SUN_HOUR=22
SCHEDULE_MON_WED_HOUR=24
SCHEDULE_THU_SUN_HOUR=24
SCHEDULE_MORNING_END_HOUR=0
# ============================================================================
@ -985,10 +985,37 @@ SERVICE_NAME="day-specific-shutdown.service"
MONITOR_SERVICE="shutdown-timer-monitor.service"
CHECK_INTERVAL=30
wait_seconds() {
local timeout_s=$1
local start_ts end_ts elapsed_s remaining_s
printf -v start_ts '%(%s)T' -1
IFS= read -r -t "$timeout_s" || true
printf -v end_ts '%(%s)T' -1
elapsed_s=$((end_ts - start_ts))
if (( elapsed_s < timeout_s )); then
remaining_s=$((timeout_s - elapsed_s))
sleep "$remaining_s"
fi
}
current_epoch() {
local out_var="${1:-}"
if [[ -n $out_var ]]; then
printf -v "$out_var" '%(%s)T' -1
else
printf '%(%s)T\n' -1
fi
}
log_message() {
local _ts
local msg
printf -v _ts '%(%Y-%m-%d %H:%M:%S)T' -1
printf '%s [shutdown-monitor] %s\n' "$_ts" "$1" | tee -a "$LOG_FILE" >&2
printf -v msg '%s [shutdown-monitor] %s' "$_ts" "$1"
printf '%s\n' "$msg" >&2
printf '%s\n' "$msg" >> "$LOG_FILE" 2>/dev/null || true
}
timer_needs_restoration() {
@ -1035,13 +1062,19 @@ restore_timer() {
monitor_with_dbus() {
log_message "Starting shutdown timer monitoring with D-Bus events"
local last_check_ts=0
if command -v busctl &>/dev/null; then
busctl monitor --system org.freedesktop.systemd1 2>/dev/null |
while read -r line; do
if [[ $line == *"$TIMER_NAME"* || $line == *"$SERVICE_NAME"* ]]; then
local now_ts
current_epoch now_ts
if (( now_ts - last_check_ts < CHECK_INTERVAL )); then
continue
fi
last_check_ts=$now_ts
log_message "Systemd event detected for shutdown timer"
sleep 2
if timer_needs_restoration; then
restore_timer
fi
@ -1060,7 +1093,7 @@ monitor_with_polling() {
if timer_needs_restoration; then
restore_timer
fi
sleep "$CHECK_INTERVAL"
wait_seconds "$CHECK_INTERVAL"
done
}
@ -1143,7 +1176,7 @@ After=multi-user.target
[Timer]
OnBootSec=60
OnUnitActiveSec=60
OnUnitActiveSec=300
Persistent=true
[Install]

View File

@ -4,7 +4,7 @@
set -euo pipefail
STATE_FILE="/var/lib/thesis-work-tracker/work-time.state"
STATE_FILE="${STATE_FILE:-/var/lib/thesis-work-tracker/work-time.state}"
# Colors
GREEN='\033[0;32m'
@ -22,19 +22,29 @@ if [[ ! -f $STATE_FILE ]]; then
fi
# Load state (need sudo to read immutable file)
if [[ $EUID -ne 0 ]]; then
if [[ -z ${THESIS_STATUS_SKIP_SUDO:-} ]] && [[ $EUID -ne 0 ]]; then
exec sudo -E bash "$0" "$@"
fi
# Temporarily remove immutable to read
sudo chattr -i "$STATE_FILE" 2>/dev/null || true
# Parse state file safely without using source
# Only extract the numeric values we need
TOTAL_WORK_SECONDS=$(grep "^TOTAL_WORK_SECONDS=" "$STATE_FILE" 2>/dev/null | cut -d= -f2 || echo "0")
STEAM_ACCESS_GRANTED=$(grep "^STEAM_ACCESS_GRANTED=" "$STATE_FILE" 2>/dev/null | cut -d= -f2 || echo "0")
CURRENT_SESSION_SECONDS=$(grep "^CURRENT_SESSION_SECONDS=" "$STATE_FILE" 2>/dev/null | cut -d= -f2 || echo "0")
LAST_WORK_SESSION_START=$(grep "^LAST_WORK_SESSION_START=" "$STATE_FILE" 2>/dev/null | cut -d= -f2 || echo "0")
# Parse state file with a single while-read pass (no grep/cut forks)
TOTAL_WORK_SECONDS=0
STEAM_ACCESS_GRANTED=0
CURRENT_SESSION_SECONDS=0
LAST_WORK_SESSION_START=0
local_key=''
local_value=''
while IFS='=' read -r local_key local_value; do
case $local_key in
TOTAL_WORK_SECONDS) TOTAL_WORK_SECONDS=$local_value ;;
STEAM_ACCESS_GRANTED) STEAM_ACCESS_GRANTED=$local_value ;;
CURRENT_SESSION_SECONDS) CURRENT_SESSION_SECONDS=$local_value ;;
LAST_WORK_SESSION_START) LAST_WORK_SESSION_START=$local_value ;;
esac
done < "$STATE_FILE" 2>/dev/null || true
# Validate that values are numeric
if ! [[ $TOTAL_WORK_SECONDS =~ ^[0-9]+$ ]]; then TOTAL_WORK_SECONDS=0; fi
@ -45,6 +55,11 @@ if ! [[ $LAST_WORK_SESSION_START =~ ^[0-9]+$ ]]; then LAST_WORK_SESSION_START=0;
# Re-apply immutable
sudo chattr +i "$STATE_FILE" 2>/dev/null || true
# Test mode: skip display and return to caller
if [[ -n ${THESIS_STATUS_SKIP_OUTPUT:-} ]]; then
return 0 2>/dev/null || exit 0
fi
# Default values if not set
TOTAL_WORK_SECONDS=${TOTAL_WORK_SECONDS:-0}
STEAM_ACCESS_GRANTED=${STEAM_ACCESS_GRANTED:-0}

View File

@ -20,6 +20,8 @@ LOCK_FILE="$STATE_DIR/tracker.lock"
LOG_DIR="/var/log/thesis-work-tracker"
LOG_FILE="$LOG_DIR/tracker.log"
CHECK_INTERVAL=15 # Check every 15 seconds
PROC_ROOT="${PROC_ROOT:-/proc}"
HOSTS_FILE="${HOSTS_FILE:-/etc/hosts}"
# Work requirements (in seconds)
# 2 hours of work = 7200 seconds required before Steam access
@ -90,7 +92,10 @@ log_message() {
local message="$*"
local timestamp
printf -v timestamp '%(%Y-%m-%d %H:%M:%S)T' -1
echo "[${timestamp}] [${level}] ${message}" | tee -a "$LOG_FILE"
local formatted
formatted="[${timestamp}] [${level}] ${message}"
printf '%s\n' "$formatted" >&2
printf '%s\n' "$formatted" >> "$LOG_FILE" 2>/dev/null || true
}
log_info() { log_message "INFO" "$@"; }
@ -102,6 +107,21 @@ log_debug() {
fi
}
wait_seconds() {
local timeout_s=$1
local start_ts end_ts elapsed_s remaining_s
printf -v start_ts '%(%s)T' -1
IFS= read -r -t "$timeout_s" || true
printf -v end_ts '%(%s)T' -1
elapsed_s=$((end_ts - start_ts))
if (( elapsed_s < timeout_s )); then
remaining_s=$((timeout_s - elapsed_s))
sleep "$remaining_s"
fi
}
# Initialize directories and state file
init_state() {
# Create directories with proper permissions
@ -120,7 +140,7 @@ init_state() {
local now_iso now_epoch
printf -v now_iso '%(%Y-%m-%d %H:%M:%S)T' -1
printf -v now_epoch '%(%s)T' -1
cat <<EOF | sudo tee "$STATE_FILE" >/dev/null
sudo bash -c "cat > '$STATE_FILE'" <<EOF
# Thesis Work Tracker State File
# DO NOT EDIT MANUALLY - Managed by thesis_work_tracker daemon
# Last updated: ${now_iso}
@ -148,14 +168,29 @@ load_state() {
# Temporarily remove immutable flag to read
sudo chattr -i "$STATE_FILE" 2>/dev/null || true
# Parse state file safely without using source
# Only extract the numeric values we need
TOTAL_WORK_SECONDS=$(grep "^TOTAL_WORK_SECONDS=" "$STATE_FILE" 2>/dev/null | cut -d= -f2 || echo "0")
STEAM_ACCESS_GRANTED=$(grep "^STEAM_ACCESS_GRANTED=" "$STATE_FILE" 2>/dev/null | cut -d= -f2 || echo "0")
CURRENT_SESSION_SECONDS=$(grep "^CURRENT_SESSION_SECONDS=" "$STATE_FILE" 2>/dev/null | cut -d= -f2 || echo "0")
LAST_WORK_SESSION_START=$(grep "^LAST_WORK_SESSION_START=" "$STATE_FILE" 2>/dev/null | cut -d= -f2 || echo "0")
# shellcheck disable=SC2034 # Written back to state file in save_state
LAST_UPDATE_TIMESTAMP=$(grep "^LAST_UPDATE_TIMESTAMP=" "$STATE_FILE" 2>/dev/null | cut -d= -f2 || echo "0")
# Parse state file safely without using source or external text helpers.
TOTAL_WORK_SECONDS=0
STEAM_ACCESS_GRANTED=0
CURRENT_SESSION_SECONDS=0
LAST_WORK_SESSION_START=0
local key value
while IFS='=' read -r key value; do
case $key in
TOTAL_WORK_SECONDS)
TOTAL_WORK_SECONDS=$value
;;
STEAM_ACCESS_GRANTED)
STEAM_ACCESS_GRANTED=$value
;;
CURRENT_SESSION_SECONDS)
CURRENT_SESSION_SECONDS=$value
;;
LAST_WORK_SESSION_START)
LAST_WORK_SESSION_START=$value
;;
esac
done < "$STATE_FILE"
# Validate that values are numeric
if ! [[ $TOTAL_WORK_SECONDS =~ ^[0-9]+$ ]]; then TOTAL_WORK_SECONDS=0; fi
@ -177,11 +212,31 @@ save_state() {
# Remove immutable flag
sudo chattr -i "$STATE_FILE" 2>/dev/null || true
# Write new state
local now_iso now_epoch
printf -v now_iso '%(%Y-%m-%d %H:%M:%S)T' -1
printf -v now_epoch '%(%s)T' -1
cat <<EOF | sudo tee "$STATE_FILE" >/dev/null
if [[ -w $STATE_FILE ]]; then
cat <<EOF > "$STATE_FILE"
# Thesis Work Tracker State File
# DO NOT EDIT MANUALLY - Managed by thesis_work_tracker daemon
# Last updated: ${now_iso}
TOTAL_WORK_SECONDS=$total_work
LAST_UPDATE_TIMESTAMP=${now_epoch}
STEAM_ACCESS_GRANTED=$steam_access
LAST_WORK_SESSION_START=$session_start
CURRENT_SESSION_SECONDS=$current_session
EOF
else
# Non-writable path: write in temp and copy with sudo.
local temp_state
temp_state=$(mktemp) || {
log_error "Failed to create temporary state file"
return 1
}
cat <<EOF > "$temp_state"
# Thesis Work Tracker State File
# DO NOT EDIT MANUALLY - Managed by thesis_work_tracker daemon
# Last updated: ${now_iso}
@ -193,6 +248,10 @@ LAST_WORK_SESSION_START=$session_start
CURRENT_SESSION_SECONDS=$current_session
EOF
sudo cp "$temp_state" "$STATE_FILE"
rm -f "$temp_state"
fi
sudo chmod 600 "$STATE_FILE"
# Re-apply immutable flag
if ! sudo chattr +i "$STATE_FILE" 2>/dev/null; then
@ -200,12 +259,6 @@ EOF
fi
}
# Check if a process is running
is_process_running() {
local process_name="$1"
pgrep -x "$process_name" >/dev/null 2>&1
}
# Get active window title and process name
get_active_window_info() {
if ! command -v xdotool &>/dev/null; then
@ -227,7 +280,11 @@ get_active_window_info() {
local process_name=""
if [[ -n $window_pid ]]; then
process_name=$(ps -p "$window_pid" -o comm= 2>/dev/null || echo "")
if [[ -r "$PROC_ROOT/$window_pid/comm" ]]; then
read -r process_name < "$PROC_ROOT/$window_pid/comm" || process_name=""
else
process_name=$(ps -p "$window_pid" -o comm= 2>/dev/null || echo "")
fi
fi
window_name=""
@ -314,24 +371,33 @@ block_distractions() {
log_info "Blocking Steam and distractions in /etc/hosts"
# Remove immutable flag temporarily
sudo chattr -i /etc/hosts 2>/dev/null || true
sudo chattr -i "$HOSTS_FILE" 2>/dev/null || true
# Add blocking entries if not already present
local hosts_modified=0
# Scan the file once to build a set of already-blocked domains (no grep fork)
local -A blocked_set=()
local scan_line scan_domain
while IFS= read -r scan_line; do
if [[ $scan_line == "0.0.0.0 "* || $scan_line == $'0.0.0.0\t'* ]]; then
read -r _ scan_domain <<<"$scan_line" 2>/dev/null || true
blocked_set[$scan_domain]=1
fi
done < "$HOSTS_FILE"
# Collect entries not yet present
local new_entries=() domain
for domain in "${STEAM_DOMAINS[@]}" "${DISTRACTION_DOMAINS[@]}"; do
if ! grep -q "^0.0.0.0[[:space:]]*$domain" /etc/hosts 2>/dev/null; then
echo "0.0.0.0 $domain" | sudo tee -a /etc/hosts >/dev/null
hosts_modified=1
if [[ -z ${blocked_set[$domain]+x} ]]; then
new_entries+=("0.0.0.0 $domain")
fi
done
# Re-apply immutable flag
sudo chattr +i /etc/hosts 2>/dev/null || true
if [[ $hosts_modified -eq 1 ]]; then
if (( ${#new_entries[@]} > 0 )); then
printf '%s\n' "${new_entries[@]}" | sudo bash -c "cat >> '$HOSTS_FILE'"
log_info "Added distraction blocks to /etc/hosts"
fi
# Re-apply immutable flag
sudo chattr +i "$HOSTS_FILE" 2>/dev/null || true
}
# Unblock Steam and distractions from /etc/hosts
@ -339,33 +405,43 @@ unblock_distractions() {
log_info "Unblocking Steam and distractions in /etc/hosts"
# Remove immutable flag temporarily
sudo chattr -i /etc/hosts 2>/dev/null || true
sudo chattr -i "$HOSTS_FILE" 2>/dev/null || true
# Remove blocking entries using mktemp for security
local temp_hosts
temp_hosts=$(mktemp) || {
log_error "Failed to create temporary file"
return 1
}
# Filter out blocked entries using bash (no sed/mktemp forks)
local new_content="" hosts_line skip domain
while IFS= read -r hosts_line; do
skip=0
for domain in "${STEAM_DOMAINS[@]}" "${DISTRACTION_DOMAINS[@]}"; do
if [[ $hosts_line == "0.0.0.0 $domain" || $hosts_line == "0.0.0.0 $domain" ]]; then
skip=1
break
fi
done
if [[ $skip -eq 0 ]]; then
new_content+="${hosts_line}"$'\n'
fi
done < "$HOSTS_FILE"
sudo cp /etc/hosts "$temp_hosts"
for domain in "${STEAM_DOMAINS[@]}" "${DISTRACTION_DOMAINS[@]}"; do
sudo sed -i "/^0.0.0.0[[:space:]]*$domain/d" "$temp_hosts"
done
sudo mv "$temp_hosts" /etc/hosts
sudo chmod 644 /etc/hosts
printf '%s' "$new_content" | sudo bash -c "cat > '$HOSTS_FILE'"
sudo chmod 644 "$HOSTS_FILE"
# Re-apply immutable flag
sudo chattr +i /etc/hosts 2>/dev/null || true
sudo chattr +i "$HOSTS_FILE" 2>/dev/null || true
log_info "Removed distraction blocks from /etc/hosts"
}
# Check if Steam is currently running (to track decay)
is_steam_running() {
pgrep -x "steam" >/dev/null 2>&1
local comm_file proc_name
for comm_file in "$PROC_ROOT"/[0-9]*/comm; do
[[ -r $comm_file ]] || continue
read -r proc_name < "$comm_file" || continue
if [[ $proc_name == "steam" ]]; then
return 0
fi
done
return 1
}
# Main tracking loop
@ -461,7 +537,7 @@ main_loop() {
last_status_log=$current_time
fi
sleep "$CHECK_INTERVAL"
wait_seconds "$CHECK_INTERVAL"
done
}

View File

@ -9,12 +9,40 @@ LOG_FILE="/var/log/hosts-file-monitor.log"
HOSTS_FILE="/etc/hosts"
HOSTS_INSTALL_SCRIPT="__HOSTS_INSTALL_SCRIPT__"
readonly MIN_HOSTS_LINES=1000
readonly EVENT_COOLDOWN_S=5
current_epoch() {
local out_var="${1:-}"
if [[ -n $out_var ]]; then
printf -v "$out_var" '%(%s)T' -1
else
printf '%(%s)T\n' -1
fi
}
wait_seconds() {
local timeout_s=$1
local start_ts end_ts elapsed_s remaining_s
printf -v start_ts '%(%s)T' -1
IFS= read -r -t "$timeout_s" || true
printf -v end_ts '%(%s)T' -1
elapsed_s=$((end_ts - start_ts))
if (( elapsed_s < timeout_s )); then
remaining_s=$((timeout_s - elapsed_s))
sleep "$remaining_s"
fi
}
# Log with timestamp (hosts-file-monitor specific)
log_message() {
local _ts
local msg
printf -v _ts '%(%Y-%m-%d %H:%M:%S)T' -1
printf '%s [hosts-monitor] %s\n' "$_ts" "$1" | tee -a "$LOG_FILE" >&2
printf -v msg '%s [hosts-monitor] %s' "$_ts" "$1"
printf '%s\n' "$msg" >&2
printf '%s\n' "$msg" >> "$LOG_FILE" 2>/dev/null || true
}
# Function to check if hosts file needs restoration
@ -83,16 +111,21 @@ restore_hosts_file() {
# Function to monitor with inotifywait
monitor_with_inotify() {
log_message "Starting hosts file monitoring with inotify"
local last_check_ts=0
# Monitor the hosts file and its directory for various events
inotifywait -m -e delete,move,modify,attrib,create --format '%w%f %e %T' --timefmt '%Y-%m-%d %H:%M:%S' "$HOSTS_FILE" /etc/ 2> /dev/null |
while read -r file event time; do
# Check if the event is related to our hosts file
if [[ $file == "$HOSTS_FILE" ]] || [[ $file == "/etc/hosts" ]]; then
log_message "Event detected: $event on $file at $time"
local now_ts
current_epoch now_ts
if (( now_ts - last_check_ts < EVENT_COOLDOWN_S )); then
continue
fi
last_check_ts=$now_ts
# Small delay to avoid rapid-fire events
sleep 2
log_message "Event detected: $event on $file at $time"
# Check if restoration is needed
if needs_restoration; then
@ -114,7 +147,7 @@ monitor_with_polling() {
fi
# Check every 30 seconds
sleep 30
wait_seconds 30
done
}

View File

@ -10,11 +10,38 @@ TIMER_NAME="day-specific-shutdown.timer"
SERVICE_NAME="day-specific-shutdown.service"
CHECK_INTERVAL=30
current_epoch() {
local out_var="${1:-}"
if [[ -n $out_var ]]; then
printf -v "$out_var" '%(%s)T' -1
else
printf '%(%s)T\n' -1
fi
}
wait_seconds() {
local timeout_s=$1
local start_ts end_ts elapsed_s remaining_s
printf -v start_ts '%(%s)T' -1
IFS= read -r -t "$timeout_s" || true
printf -v end_ts '%(%s)T' -1
elapsed_s=$((end_ts - start_ts))
if (( elapsed_s < timeout_s )); then
remaining_s=$((timeout_s - elapsed_s))
sleep "$remaining_s"
fi
}
# Log with timestamp (shutdown-timer-monitor specific)
log_message() {
local _ts
local msg
printf -v _ts '%(%Y-%m-%d %H:%M:%S)T' -1
printf '%s [shutdown-monitor] %s\n' "$_ts" "$1" | tee -a "$LOG_FILE" >&2
printf -v msg '%s [shutdown-monitor] %s' "$_ts" "$1"
printf '%s\n' "$msg" >&2
printf '%s\n' "$msg" >> "$LOG_FILE" 2>/dev/null || true
}
# Function to check if timer needs to be re-enabled
@ -82,6 +109,7 @@ restore_timer() {
# Function to monitor timer with systemd events
monitor_with_dbus() {
log_message "Starting shutdown timer monitoring with D-Bus events"
local last_check_ts=0
# Use busctl to monitor systemd unit changes
# Fall back to polling if this fails.
@ -91,8 +119,13 @@ monitor_with_dbus() {
while read -r line; do
# Check if the line mentions our timer
if [[ $line == *"$TIMER_NAME"* || $line == *"$SERVICE_NAME"* ]]; then
local now_ts
current_epoch now_ts
if (( now_ts - last_check_ts < CHECK_INTERVAL )); then
continue
fi
last_check_ts=$now_ts
log_message "Systemd event detected for shutdown timer"
sleep 2
if timer_needs_restoration; then
restore_timer
fi
@ -112,7 +145,7 @@ monitor_with_polling() {
if timer_needs_restoration; then
restore_timer
fi
sleep "$CHECK_INTERVAL"
wait_seconds "$CHECK_INTERVAL"
done
}

View File

@ -101,4 +101,39 @@ poll_mode=$(run_shell "source '$WORKTREE/scripts/system-maintenance/bin/hosts-fi
mv "$BIN_DIR/inotifywait.off" "$BIN_DIR/inotifywait"
assert_equals 'polling' "$poll_mode" 'start_monitoring should fall back to polling when inotifywait is absent'
printf 'Checking inotify event path avoids per-event sleep and debounces bursts...\n'
sleep_log="$TMP_DIR/sleep.log"
: >"$sleep_log"
counter_file="$TMP_DIR/debounce-count.log"
: >"$counter_file"
debounce_calls=$(env -i PATH="$BIN_DIR" HOSTS_FILE_MONITOR_SKIP_MAIN=1 SLEEP_LOG="$sleep_log" COUNTER_FILE="$counter_file" MOCK_INOTIFY_OUTPUT=$'/etc/hosts MODIFY 2026-01-01 00:00:00\n/etc/hosts ATTRIB 2026-01-01 00:00:01\n/etc/hosts MODIFY 2026-01-01 00:00:02' /bin/bash -c \
"source '$WORKTREE/scripts/system-maintenance/bin/hosts-file-monitor.sh'; \
needs_restoration() { printf 'x\n' >> \"\$COUNTER_FILE\"; return 1; }; \
idx=0; \
current_epoch() { \
local out_var=\"\${1:-}\"; \
local ts; \
case \$idx in 0) ts='100';; 1) ts='101';; 2) ts='106';; *) ts='999';; esac; \
idx=\$((idx + 1)); \
if [[ -n \$out_var ]]; then printf -v \"\$out_var\" '%s' \"\$ts\"; else printf '%s\\n' \"\$ts\"; fi; \
}; \
monitor_with_inotify >/dev/null 2>&1 || true; \
total=0; \
while IFS= read -r _; do total=\$((total + 1)); done < \"\$COUNTER_FILE\"; \
printf '%s' \"\$total\"")
assert_equals '2' "$debounce_calls" 'monitor_with_inotify should debounce rapid successive events'
if [[ -s $sleep_log ]]; then
fail 'monitor_with_inotify should not call sleep in the event path'
fi
printf 'Checking polling wait helper enforces delay on /dev/null stdin...\n'
wait_elapsed=$(env -i PATH="/usr/bin:/bin" HOSTS_FILE_MONITOR_SKIP_MAIN=1 /bin/bash -c \
"source '$WORKTREE/scripts/system-maintenance/bin/hosts-file-monitor.sh'; \
start=\$(printf '%(%s)T' -1); \
wait_seconds 1; \
end=\$(printf '%(%s)T' -1); \
printf '%s' \$((end-start))" </dev/null)
assert_equals '1' "$wait_elapsed" 'wait_seconds should not return immediately on /dev/null stdin'
printf 'hosts-file-monitor.sh regression checks passed.\n'

View File

@ -30,6 +30,15 @@ assert_eq() {
fi
}
assert_le() {
local actual=$1
local expected_max=$2
local context=$3
if (( actual > expected_max )); then
fail "$context (expected <= '$expected_max', actual '$actual')"
fi
}
count_execs() {
local script_path=$1
local log_file=$2
@ -143,4 +152,11 @@ chmod +x "$fork_probe"
exec_count=$(count_execs "$fork_probe" "$TMP_DIR/fork_probe.trace")
assert_eq '1' "$exec_count" 'persist helper hot path should not fork external commands'
printf 'Checking wait helper supports test skip mode...\n'
SECONDS=0
export I3BLOCKS_TEST_SKIP_WAIT=1
i3blocks_wait_seconds 5
assert_le "$SECONDS" 1 'wait helper should return immediately in test skip mode'
unset I3BLOCKS_TEST_SKIP_WAIT
printf 'persist_common helper regression tests passed.\n'

View File

@ -57,52 +57,78 @@ log_message() {
EOF
chmod +x "$WORKTREE/scripts/lib/common.sh"
cat >"$BIN_DIR/pgrep" <<'EOF'
#!/bin/bash
if [[ ${MOCK_MUSIC_RUNNING:-0} -eq 1 ]]; then
exit 0
fi
exit 1
EOF
chmod +x "$BIN_DIR/pgrep"
cat >"$BIN_DIR/pkill" <<'EOF'
#!/bin/bash
exit 0
EOF
chmod +x "$BIN_DIR/pkill"
cat >"$BIN_DIR/sleep" <<'EOF'
#!/bin/bash
printf '%s\n' "$1" >> "${SLEEP_LOG:?}"
exit 99
EOF
chmod +x "$BIN_DIR/sleep"
create_fake_proc_process() {
local proc_root="$1"
local pid="$2"
local name="$3"
mkdir -p "$proc_root/$pid"
printf '%s\n' "$name" >"$proc_root/$pid/comm"
}
run_case() {
local expected_sleep="$1"
local expected_wait="$1"
local focus_active="$2"
local music_running="$3"
local sleep_log="$TMP_DIR/sleep.log"
local music_proc_name="${3:-}"
local mode="${4:-instant}"
local wait_log="$TMP_DIR/wait.log"
local proc_root="$TMP_DIR/proc"
: >"$wait_log"
rm -rf "$proc_root"
mkdir -p "$proc_root"
if [[ -n $music_proc_name ]]; then
create_fake_proc_process "$proc_root" 4242 "$music_proc_name"
fi
: >"$sleep_log"
PATH="$BIN_DIR:$PATH" \
SLEEP_LOG="$sleep_log" \
MUSIC_PARALLELISM_TEST_WAIT_LOG="$wait_log" \
MUSIC_PARALLELISM_TEST_EXIT_AFTER_WAIT=1 \
XDOTOOL_LOG="${XDOTOOL_LOG:-}" \
PROC_ROOT="$proc_root" \
MOCK_FOCUS_ACTIVE="$focus_active" \
MOCK_MUSIC_RUNNING="$music_running" \
bash "$WORKTREE/scripts/digital_wellbeing/music_parallelism.sh" instant \
bash "$WORKTREE/scripts/digital_wellbeing/music_parallelism.sh" "$mode" \
>/dev/null 2>&1 || true
assert_equals "$expected_sleep" "$(<"$sleep_log")" "music_parallelism.sh should pick the expected sleep interval"
assert_equals "$expected_wait" "$(<"$wait_log")" "music_parallelism.sh should pick the expected wait interval"
}
printf 'Checking stable-focus backoff uses the slower interval...\n'
run_case 15 1 0
run_case 15 1
printf 'Checking conflict handling uses the faster retry interval...\n'
run_case 5 1 1
run_case 5 1 spotify
printf 'Checking idle mode uses the idle interval...\n'
run_case 30 0 0
run_case 30 0
printf 'Checking conflict path avoids duplicate xdotool searches...\n'
xdotool_log="$TMP_DIR/xdotool.log"
: >"$xdotool_log"
XDOTOOL_LOG="$xdotool_log"
cat >"$BIN_DIR/xdotool" <<'EOF'
#!/bin/bash
printf '%s\n' "$1" >> "${XDOTOOL_LOG:?}"
if [[ ${1:-} == search ]]; then
exit 1
fi
if [[ ${1:-} == windowclose ]]; then
exit 0
fi
exit 0
EOF
chmod +x "$BIN_DIR/xdotool"
run_case 5 1 spotify
search_calls=$(grep -c '^search$' "$xdotool_log" 2>/dev/null || true)
assert_equals '1' "$search_calls" 'music_parallelism.sh should avoid duplicate xdotool search calls when process-only music is detected'
printf 'Checking monitor loop also avoids duplicate xdotool searches...\n'
: >"$xdotool_log"
run_case 15 1 spotify monitor
monitor_search_calls=$(grep -c '^search$' "$xdotool_log" 2>/dev/null || true)
assert_equals '1' "$monitor_search_calls" 'music_parallelism.sh monitor loop should avoid duplicate xdotool search calls when process-only music is detected'
printf 'music_parallelism.sh regression checks passed.\n'

View File

@ -36,7 +36,8 @@ cp "$TARGET_SCRIPT" "$WORKTREE/scripts/system-maintenance/bin/shutdown-timer-mon
cat >"$BIN_DIR/busctl" <<'EOF'
#!/bin/bash
if [[ $1 == monitor ]]; then
printf '%s\n' "${MOCK_BUSCTL_LINE:-no relevant event}" | while read -r line; do
payload=${MOCK_BUSCTL_LINES:-${MOCK_BUSCTL_LINE:-no relevant event}}
printf '%b\n' "$payload" | while read -r line; do
printf '%s\n' "$line"
done
exit 0
@ -106,12 +107,65 @@ run_case() {
fi
}
run_dbus_throttle_case() {
local ts_sequence="$1"
local expected_calls="$2"
local check_interval="${3:-30}"
local calls
local counter_file="$TMP_DIR/timer_checks.log"
: >"$counter_file"
calls=$(env -i PATH="$BIN_DIR" SHUTDOWN_TIMER_MONITOR_SKIP_MAIN=1 MOCK_BUSCTL_LINES="day-specific-shutdown.timer\nday-specific-shutdown.timer\nday-specific-shutdown.timer" MOCK_TS_SEQUENCE="$ts_sequence" COUNTER_FILE="$counter_file" TEST_CHECK_INTERVAL="$check_interval" /bin/bash -c '
source "$1"
CHECK_INTERVAL="$TEST_CHECK_INTERVAL"
timer_needs_restoration() { printf "x\n" >> "$COUNTER_FILE"; return 1; }
restore_timer() { :; }
mock_idx=0
IFS=" " read -r -a mock_ts <<< "$MOCK_TS_SEQUENCE"
current_epoch() {
local out_var="${1:-}"
local ts_value="${mock_ts[$mock_idx]:-0}"
mock_idx=$((mock_idx + 1))
if [[ -n $out_var ]]; then
printf -v "$out_var" '%s' "$ts_value"
else
printf "%s\n" "$ts_value"
fi
}
monitor_with_dbus >/dev/null 2>&1 || true
timer_checks=0
while IFS= read -r _; do
timer_checks=$((timer_checks + 1))
done < "$COUNTER_FILE"
printf "%s" "$timer_checks"
' _ "$WORKTREE/scripts/system-maintenance/bin/shutdown-timer-monitor.sh")
assert_equals "$expected_calls" "$calls" 'monitor_with_dbus should throttle repeated relevant events'
}
printf 'Checking D-Bus path is preferred when busctl exists...\n'
run_case dbus 1
printf 'Checking polling fallback is used when busctl is absent...\n'
run_case polling 0
printf 'Checking D-Bus monitor throttles repeated events within interval...\n'
run_dbus_throttle_case '100 105 109' '1'
printf 'Checking D-Bus monitor can process all events when interval is zero...\n'
run_dbus_throttle_case '100 101 102' '3' '0'
printf 'Checking wait helper enforces delay even with /dev/null stdin...\n'
wait_elapsed=$(env -i PATH="/usr/bin:/bin" SHUTDOWN_TIMER_MONITOR_SKIP_MAIN=1 /bin/bash -c \
"source '$WORKTREE/scripts/system-maintenance/bin/shutdown-timer-monitor.sh'; \
start=\$(printf '%(%s)T' -1); \
wait_seconds 1; \
end=\$(printf '%(%s)T' -1); \
printf '%s' \$((end-start))" </dev/null)
assert_equals '1' "$wait_elapsed" 'wait_seconds should not return immediately on /dev/null stdin'
printf 'Checking installer template stays in sync with the event-driven monitor...\n'
grep -Fq 'monitor_with_dbus()' "$SETUP_SCRIPT" \
|| fail 'setup_midnight_shutdown.sh should install the D-Bus monitor helper'
@ -119,5 +173,19 @@ grep -Fq 'start_monitoring()' "$SETUP_SCRIPT" \
|| fail 'setup_midnight_shutdown.sh should install the start_monitoring dispatcher'
grep -Fq 'if command -v busctl &>/dev/null; then' "$SETUP_SCRIPT" \
|| fail 'setup_midnight_shutdown.sh should prefer busctl when available'
grep -Fq 'current_epoch now_ts' "$SETUP_SCRIPT" \
|| fail 'setup_midnight_shutdown.sh should use out-var epoch helper in D-Bus throttling path'
if grep -Fq 'now_ts=$(current_epoch)' "$SETUP_SCRIPT"; then
fail 'setup_midnight_shutdown.sh should avoid subshell epoch capture in D-Bus path'
fi
if grep -Fq 'now_ts=$(current_epoch)' "$TARGET_SCRIPT"; then
fail 'runtime shutdown monitor should avoid subshell epoch capture in D-Bus path'
fi
grep -Fq 'OnUnitActiveSec=300' "$SETUP_SCRIPT" \
|| fail 'setup_midnight_shutdown.sh should run watchdog timer at 300s cadence'
grep -Fq 'wait_seconds()' "$SETUP_SCRIPT" \
|| fail 'setup_midnight_shutdown.sh should install builtin wait helper in polling fallback'
grep -Fq 'wait_seconds "$CHECK_INTERVAL"' "$TARGET_SCRIPT" \
|| fail 'runtime shutdown monitor polling fallback should use builtin wait helper'
printf 'shutdown-timer-monitor.sh regression checks passed.\n'

View File

@ -0,0 +1,88 @@
#!/bin/bash
# Regression tests for thesis_work_status.sh state-parsing helper behavior.
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd)
TARGET_SCRIPT="$REPO_DIR/scripts/digital_wellbeing/thesis_work_status.sh"
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
assert_equals() {
local expected="$1"
local actual="$2"
local context="$3"
if [[ "$expected" != "$actual" ]]; then
fail "$context (expected: '$expected', actual: '$actual')"
fi
}
TMP_DIR=$(mktemp -d)
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
WORKTREE="$TMP_DIR/worktree"
BIN_DIR="$TMP_DIR/bin"
mkdir -p "$WORKTREE/scripts/digital_wellbeing" "$BIN_DIR"
cp "$TARGET_SCRIPT" "$WORKTREE/scripts/digital_wellbeing/thesis_work_status.sh"
# sudo stub — passes through all commands
cat >"$BIN_DIR/sudo" <<'EOF'
#!/bin/bash
"$@"
EOF
chmod +x "$BIN_DIR/sudo"
# chattr stub
cat >"$BIN_DIR/chattr" <<'EOF'
#!/bin/bash
exit 0
EOF
chmod +x "$BIN_DIR/chattr"
# grep stub — must NOT be called by state parsing
cat >"$BIN_DIR/grep" <<'EOF'
#!/bin/bash
printf 'grep should not be called\n' >&2
exit 1
EOF
chmod +x "$BIN_DIR/grep"
# cut stub — must NOT be called by state parsing
cat >"$BIN_DIR/cut" <<'EOF'
#!/bin/bash
printf 'cut should not be called\n' >&2
exit 1
EOF
chmod +x "$BIN_DIR/cut"
STATE_PATH="$TMP_DIR/work-time.state"
cat >"$STATE_PATH" <<'EOF'
# Thesis Work Tracker State File
TOTAL_WORK_SECONDS=3600
LAST_UPDATE_TIMESTAMP=1715400000
STEAM_ACCESS_GRANTED=1
LAST_WORK_SESSION_START=42
CURRENT_SESSION_SECONDS=90
EOF
printf 'Checking state parsing does not depend on grep/cut...\n'
# THESIS_STATUS_SKIP_SUDO=1 prevents exec sudo re-exec
# THESIS_STATUS_SKIP_OUTPUT=1 prevents display output; script returns after parsing
parsed_vals=$(PATH="$BIN_DIR:$PATH" THESIS_STATUS_SKIP_SUDO=1 THESIS_STATUS_SKIP_OUTPUT=1 \
bash -lc \
"STATE_FILE='$STATE_PATH'; \
source '$WORKTREE/scripts/digital_wellbeing/thesis_work_status.sh'; \
printf '%s|%s|%s|%s' \"\$TOTAL_WORK_SECONDS\" \"\$STEAM_ACCESS_GRANTED\" \"\$CURRENT_SESSION_SECONDS\" \"\$LAST_WORK_SESSION_START\"" \
2>/dev/null)
assert_equals '3600|1|90|42' "$parsed_vals" \
'thesis_work_status state parsing should work without grep/cut dependency'
printf 'thesis_work_status.sh regression checks passed.\n'

View File

@ -75,11 +75,39 @@ source_env() {
PATH="$BIN_DIR:$PATH" THESIS_WORK_TRACKER_SKIP_MAIN=1 bash -lc "source '$WORKTREE/scripts/digital_wellbeing/thesis_work_tracker.sh'; $1"
}
source_env_with_proc() {
local proc_root="$1"
local cmd="$2"
PATH="$BIN_DIR:$PATH" PROC_ROOT="$proc_root" THESIS_WORK_TRACKER_SKIP_MAIN=1 bash -lc "source '$WORKTREE/scripts/digital_wellbeing/thesis_work_tracker.sh'; $cmd"
}
printf 'Checking helper output for VS Code on thesis repo...\n'
result=$(source_env 'get_active_window_info')
assert_equals 'Code|Document - praca_magisterska - Visual Studio Code' "$result" \
'get_active_window_info should return process and title for VS Code'
printf 'Checking helper reads process name from /proc before ps...\n'
PROC_WINDOW_DIR="$TMP_DIR/proc-window"
mkdir -p "$PROC_WINDOW_DIR/6789"
printf 'Code\n' >"$PROC_WINDOW_DIR/6789/comm"
cat >"$BIN_DIR/ps" <<'EOF'
#!/bin/bash
printf 'ps should not be called\n' >&2
exit 1
EOF
chmod +x "$BIN_DIR/ps"
result_proc=$(MOCK_WINDOW_TITLE='Document - praca_magisterska - Visual Studio Code' \
source_env_with_proc "$PROC_WINDOW_DIR" 'get_active_window_info')
assert_equals 'Code|Document - praca_magisterska - Visual Studio Code' "$result_proc" \
'get_active_window_info should read process name from proc comm without ps fallback'
cat >"$BIN_DIR/ps" <<'EOF'
#!/bin/bash
printf '%s\n' "${MOCK_PROCESS_NAME:-Code}"
EOF
chmod +x "$BIN_DIR/ps"
printf 'Checking thesis detection for VS Code thesis repo...\n'
active=$(source_env 'is_thesis_work_active && printf yes || printf no')
assert_equals 'yes' "$active" 'thesis detection should accept VS Code on the thesis repo'
@ -88,4 +116,228 @@ printf 'Checking thesis detection skips non-thesis VS Code windows...\n'
non_thesis=$(MOCK_WINDOW_TITLE='Document - notes - Visual Studio Code' source_env 'is_thesis_work_active && printf yes || printf no')
assert_equals 'no' "$non_thesis" 'thesis detection should reject VS Code outside the thesis repo'
printf 'Checking steam detection reads process state from /proc without pgrep...\n'
PROC_DIR="$TMP_DIR/proc"
mkdir -p "$PROC_DIR/999"
printf 'steam\n' >"$PROC_DIR/999/comm"
steam_running=$(source_env_with_proc "$PROC_DIR" 'is_steam_running && printf yes || printf no')
assert_equals 'yes' "$steam_running" 'is_steam_running should detect steam via proc comm files'
printf 'Checking logging path does not depend on tee...\n'
cat >"$BIN_DIR/tee" <<'EOF'
#!/bin/bash
printf 'tee should not be called\n' >&2
exit 1
EOF
chmod +x "$BIN_DIR/tee"
LOG_PATH="$TMP_DIR/tracker.log"
set +e
log_result=$(PATH="$BIN_DIR:$PATH" THESIS_WORK_TRACKER_SKIP_MAIN=1 bash -lc \
"source '$WORKTREE/scripts/digital_wellbeing/thesis_work_tracker.sh'; LOG_FILE='$LOG_PATH'; log_info 'logging regression test'; printf ok")
log_ec=$?
set -e
assert_equals '0' "$log_ec" 'log_info should not fail when tee is unavailable'
assert_equals 'ok' "$log_result" 'log_info should succeed without tee dependency'
grep -q 'logging regression test' "$LOG_PATH" \
|| fail 'log_info should append message to the log file'
printf 'Checking state loading does not depend on grep/cut...\n'
cat >"$BIN_DIR/sudo" <<'EOF'
#!/bin/bash
"$@"
EOF
chmod +x "$BIN_DIR/sudo"
cat >"$BIN_DIR/chattr" <<'EOF'
#!/bin/bash
exit 0
EOF
chmod +x "$BIN_DIR/chattr"
cat >"$BIN_DIR/grep" <<'EOF'
#!/bin/bash
printf 'grep should not be called\n' >&2
exit 1
EOF
chmod +x "$BIN_DIR/grep"
cat >"$BIN_DIR/cut" <<'EOF'
#!/bin/bash
printf 'cut should not be called\n' >&2
exit 1
EOF
chmod +x "$BIN_DIR/cut"
STATE_PATH="$TMP_DIR/work-time.state"
cat >"$STATE_PATH" <<'EOF'
# Thesis Work Tracker State File
TOTAL_WORK_SECONDS=123
LAST_UPDATE_TIMESTAMP=1715400000
STEAM_ACCESS_GRANTED=1
LAST_WORK_SESSION_START=77
CURRENT_SESSION_SECONDS=15
EOF
loaded_state=$(PATH="$BIN_DIR:$PATH" THESIS_WORK_TRACKER_SKIP_MAIN=1 bash -lc \
"source '$WORKTREE/scripts/digital_wellbeing/thesis_work_tracker.sh'; \
STATE_FILE='$STATE_PATH'; \
load_state; \
printf '%s|%s|%s|%s' \"\$TOTAL_WORK_SECONDS\" \"\$STEAM_ACCESS_GRANTED\" \"\$CURRENT_SESSION_SECONDS\" \"\$LAST_WORK_SESSION_START\"")
assert_equals '123|1|15|77' "$loaded_state" 'load_state should parse values without grep/cut dependency'
printf 'Checking state saving does not depend on tee...\n'
set +e
save_state_result=$(PATH="$BIN_DIR:$PATH" THESIS_WORK_TRACKER_SKIP_MAIN=1 bash -lc \
"source '$WORKTREE/scripts/digital_wellbeing/thesis_work_tracker.sh'; \
STATE_FILE='$STATE_PATH'; \
STATE_DIR='$(dirname "$STATE_PATH")'; \
save_state 321 0 45 9; \
printf ok")
save_state_ec=$?
set -e
assert_equals '0' "$save_state_ec" 'save_state should not fail when tee is unavailable'
assert_equals 'ok' "$save_state_result" 'save_state should complete successfully without tee dependency'
saved_state=$(PATH="$BIN_DIR:$PATH" THESIS_WORK_TRACKER_SKIP_MAIN=1 bash -lc \
"source '$WORKTREE/scripts/digital_wellbeing/thesis_work_tracker.sh'; \
STATE_FILE='$STATE_PATH'; \
load_state; \
printf '%s|%s|%s|%s' \"\$TOTAL_WORK_SECONDS\" \"\$STEAM_ACCESS_GRANTED\" \"\$CURRENT_SESSION_SECONDS\" \"\$LAST_WORK_SESSION_START\"")
assert_equals '321|0|45|9' "$saved_state" 'save_state should persist updated values without tee dependency'
printf 'Checking writable save path does not require mktemp...\n'
cat >"$BIN_DIR/mktemp" <<'EOF'
#!/bin/bash
printf 'mktemp should not be called\n' >&2
exit 1
EOF
chmod +x "$BIN_DIR/mktemp"
set +e
save_fast_result=$(PATH="$BIN_DIR:$PATH" THESIS_WORK_TRACKER_SKIP_MAIN=1 bash -lc \
"source '$WORKTREE/scripts/digital_wellbeing/thesis_work_tracker.sh'; \
STATE_FILE='$STATE_PATH'; \
STATE_DIR='$(dirname "$STATE_PATH")'; \
save_state 654 1 30 11; \
printf ok")
save_fast_ec=$?
set -e
assert_equals '0' "$save_fast_ec" 'save_state should not require mktemp when state file is writable'
assert_equals 'ok' "$save_fast_result" 'save_state fast path should complete successfully'
saved_fast_state=$(PATH="$BIN_DIR:$PATH" THESIS_WORK_TRACKER_SKIP_MAIN=1 bash -lc \
"source '$WORKTREE/scripts/digital_wellbeing/thesis_work_tracker.sh'; \
STATE_FILE='$STATE_PATH'; \
load_state; \
printf '%s|%s|%s|%s' \"\$TOTAL_WORK_SECONDS\" \"\$STEAM_ACCESS_GRANTED\" \"\$CURRENT_SESSION_SECONDS\" \"\$LAST_WORK_SESSION_START\"")
assert_equals '654|1|30|11' "$saved_fast_state" 'save_state writable fast path should persist values'
printf 'Checking block_distractions does not depend on grep or tee...\n'
HOSTS_PATH="$TMP_DIR/hosts-block"
printf '# /etc/hosts baseline\n127.0.0.1 localhost\n' >"$HOSTS_PATH"
cat >"$BIN_DIR/grep" <<'EOF'
#!/bin/bash
printf 'grep should not be called\n' >&2
exit 1
EOF
chmod +x "$BIN_DIR/grep"
cat >"$BIN_DIR/tee" <<'EOF'
#!/bin/bash
printf 'tee should not be called\n' >&2
exit 1
EOF
chmod +x "$BIN_DIR/tee"
set +e
block_result=$(PATH="$BIN_DIR:$PATH" THESIS_WORK_TRACKER_SKIP_MAIN=1 bash -lc \
"source '$WORKTREE/scripts/digital_wellbeing/thesis_work_tracker.sh'; \
HOSTS_FILE='$HOSTS_PATH'; \
block_distractions; \
printf ok")
block_ec=$?
set -e
assert_equals '0' "$block_ec" 'block_distractions should succeed without grep/tee'
assert_equals 'ok' "$block_result" 'block_distractions should complete without grep/tee dependency'
grep -q '0.0.0.0 steampowered.com' "$HOSTS_PATH" \
|| fail 'block_distractions should add steampowered.com entry to hosts file'
grep -q '0.0.0.0 reddit.com' "$HOSTS_PATH" \
|| fail 'block_distractions should add reddit.com entry to hosts file'
grep -q 'localhost' "$HOSTS_PATH" \
|| fail 'block_distractions should preserve existing localhost entry'
printf 'Checking block_distractions is idempotent (no duplicate entries)...\n'
set +e
block_result2=$(PATH="$BIN_DIR:$PATH" THESIS_WORK_TRACKER_SKIP_MAIN=1 bash -lc \
"source '$WORKTREE/scripts/digital_wellbeing/thesis_work_tracker.sh'; \
HOSTS_FILE='$HOSTS_PATH'; \
block_distractions; \
printf ok")
block_ec2=$?
set -e
assert_equals '0' "$block_ec2" 'block_distractions second run should succeed'
assert_equals 'ok' "$block_result2" 'block_distractions idempotent run should complete successfully'
count=$(grep -c '0.0.0.0 steampowered.com' "$HOSTS_PATH" || true)
assert_equals '1' "$count" 'block_distractions should not add duplicate entries'
printf 'Checking unblock_distractions does not depend on sed or mktemp...\n'
HOSTS_UNBLOCK_PATH="$TMP_DIR/hosts-unblock"
printf '# /etc/hosts baseline\n127.0.0.1 localhost\n0.0.0.0 steampowered.com\n0.0.0.0 reddit.com\n0.0.0.0 youtube.com\n' >"$HOSTS_UNBLOCK_PATH"
cat >"$BIN_DIR/sed" <<'EOF'
#!/bin/bash
printf 'sed should not be called\n' >&2
exit 1
EOF
chmod +x "$BIN_DIR/sed"
cat >"$BIN_DIR/mktemp" <<'EOF'
#!/bin/bash
printf 'mktemp should not be called\n' >&2
exit 1
EOF
chmod +x "$BIN_DIR/mktemp"
set +e
unblock_result=$(PATH="$BIN_DIR:$PATH" THESIS_WORK_TRACKER_SKIP_MAIN=1 bash -lc \
"source '$WORKTREE/scripts/digital_wellbeing/thesis_work_tracker.sh'; \
HOSTS_FILE='$HOSTS_UNBLOCK_PATH'; \
unblock_distractions; \
printf ok")
unblock_ec=$?
set -e
assert_equals '0' "$unblock_ec" 'unblock_distractions should succeed without sed/mktemp'
assert_equals 'ok' "$unblock_result" 'unblock_distractions should complete without sed/mktemp dependency'
if grep -q '0.0.0.0 steampowered.com' "$HOSTS_UNBLOCK_PATH" 2>/dev/null; then
fail 'unblock_distractions should remove steampowered.com entry'
fi
if grep -q '0.0.0.0 reddit.com' "$HOSTS_UNBLOCK_PATH" 2>/dev/null; then
fail 'unblock_distractions should remove reddit.com entry'
fi
grep -q 'localhost' "$HOSTS_UNBLOCK_PATH" \
|| fail 'unblock_distractions should preserve localhost entry'
printf 'Checking init_state does not depend on tee...\n'
STATE_INIT_PATH="$TMP_DIR/init-state-file.state"
STATE_INIT_DIR="$TMP_DIR/init-state-dir"
mkdir -p "$STATE_INIT_DIR"
set +e
init_result=$(PATH="$BIN_DIR:$PATH" THESIS_WORK_TRACKER_SKIP_MAIN=1 bash -lc \
"source '$WORKTREE/scripts/digital_wellbeing/thesis_work_tracker.sh'; \
STATE_FILE='$STATE_INIT_PATH'; \
STATE_DIR='$STATE_INIT_DIR'; \
LOG_DIR='$TMP_DIR'; \
LOG_FILE='$TMP_DIR/init-tracker.log'; \
init_state; \
printf ok")
init_ec=$?
set -e
assert_equals '0' "$init_ec" 'init_state should succeed without tee dependency'
assert_equals 'ok' "$init_result" 'init_state should complete without tee dependency'
grep -q 'TOTAL_WORK_SECONDS=0' "$STATE_INIT_PATH" \
|| fail 'init_state should write TOTAL_WORK_SECONDS=0 to state file'
printf 'thesis_work_tracker.sh regression checks passed.\n'