From f5edebae4a4ed25afcd7c5a44d7bfa079f689cff Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 21 Aug 2026 15:51:06 +0200 Subject: [PATCH] block_compulsive_opening: split into lib/ under the 250-line cap 705 -> 182 lines, with state, the launch gate, install/uninstall and reporting moved into cco_state.sh, cco_wrapper.sh, cco_install.sh and cco_report.sh. The entry script keeps its exact deployed path and stays a real file there, not a symlink: the app wrappers already installed on disk hard-code /usr/local/bin/block-compulsive-opening.sh, and the pacman hook invokes it. _CCO_LIB_DIR probes for lib/ beside the script and falls back to the script's own directory, so the repo tree and the flat /usr/local/bin deploy layout both work; both branches were exercised. install_all deploys the libs and uninstall_all removes them in this same commit, so there is never a deployed copy that is split but missing its libs -- the failure that had to be repaired for install_leechblock. The libs deploy flat into /usr/local/bin rather than into /usr/local/share/digital_wellbeing/lib, because install_leechblock_payload does an rm -rf mirror of that directory and would delete them. Verified beyond the usual gates: a line-multiset check reports zero missing functional lines, and all three heredoc bodies -- the generated app wrapper, the generated pacman hook and the help text -- are byte-identical, which verify_shell_split.sh cannot check. status output matches the still-monolithic deployed copy byte for byte. Co-Authored-By: Claude Opus 5 (1M context) --- ...k-compulsive-opening-split-2026-08-21.json | 20 + ...k-compulsive-opening-split-2026-08-21.json | 64 ++ .../block_compulsive_opening.sh | 607 ++---------------- .../digital_wellbeing/lib/cco_install.sh | 238 +++++++ .../digital_wellbeing/lib/cco_report.sh | 102 +++ .../digital_wellbeing/lib/cco_state.sh | 149 +++++ .../digital_wellbeing/lib/cco_wrapper.sh | 120 ++++ 7 files changed, 735 insertions(+), 565 deletions(-) create mode 100644 docs/superpowers/contracts/block-compulsive-opening-split-2026-08-21.json create mode 100644 docs/superpowers/evidence/block-compulsive-opening-split-2026-08-21.json create mode 100755 linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_install.sh create mode 100755 linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_report.sh create mode 100755 linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_state.sh create mode 100755 linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_wrapper.sh diff --git a/docs/superpowers/contracts/block-compulsive-opening-split-2026-08-21.json b/docs/superpowers/contracts/block-compulsive-opening-split-2026-08-21.json new file mode 100644 index 00000000..41357baa --- /dev/null +++ b/docs/superpowers/contracts/block-compulsive-opening-split-2026-08-21.json @@ -0,0 +1,20 @@ +{ + "title": "Split block_compulsive_opening.sh under the 250-line cap", + "objective": "Bring linux_configuration/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh (705 lines) under the repo-wide 250-line file cap by moving its state, wrapper, install and reporting concerns into four sourced libs, preserving behaviour exactly and keeping the deployed /usr/local/bin entry path intact so already-installed app wrappers and the pacman hook continue to work.", + "acceptance_criteria": [ + "The entry script and all four lib/cco_*.sh files are each at most 250 lines.", + "A line-multiset check against the pre-split file reports zero missing functional lines.", + "All three heredoc bodies -- the generated app wrapper, the generated pacman hook, and the help text -- are byte-identical to the pre-split versions, since they define deployed artifacts that verify_shell_split.sh cannot inspect.", + "status output is byte-identical to the still-monolithic deployed copy, and both exit 0.", + "The script runs correctly in BOTH layouts: lib/ beside the script (repo) and libs flat beside the script (deployed), and fails closed with exit 1 when a lib is missing.", + "install_all deploys the libs and uninstall_all removes them, in this same commit, so the deployed copy is never split-without-libs.", + "shellcheck passes without -x and shfmt -w is a no-op on all five files, with no suppression added." + ], + "out_of_scope": [ + "Shell test coverage for the new libs -- no suite exists for this script; the split is verified behaviourally and textually.", + "Changing the deployed entry path or converting it to a symlink -- installed app wrappers hard-code /usr/local/bin/block-compulsive-opening.sh.", + "Deploying the libs under /usr/local/share/digital_wellbeing/lib, which install_leechblock_payload mirrors with rm -rf.", + "Any change to the app timeout policy, the hour-bucket semantics, or the managed app list." + ], + "verifier": "bash meta/scripts/check_file_length.sh --all for the cap; a python3 line-multiset diff and per-heredoc body diffs against HEAD for content equivalence; status output compared against the pre-split deployed copy; a flat-layout run plus a deleted-lib run for the two _CCO_LIB_DIR branches and the fail-closed guard; shellcheck, shfmt idempotence and bash -n on all five files; pre-commit run --files on the staged set." +} diff --git a/docs/superpowers/evidence/block-compulsive-opening-split-2026-08-21.json b/docs/superpowers/evidence/block-compulsive-opening-split-2026-08-21.json new file mode 100644 index 00000000..18442494 --- /dev/null +++ b/docs/superpowers/evidence/block-compulsive-opening-split-2026-08-21.json @@ -0,0 +1,64 @@ +{ + "intent": "Bring block_compulsive_opening.sh (705 lines) under the 250-line cap by moving its state, wrapper, install and reporting logic into four sourced libs, without changing behaviour and without breaking the already-installed app wrappers that hard-code the deployed script's path.", + "scope": [ + "linux_configuration/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh: 705 -> 182 lines", + "New: lib/cco_install.sh (238), lib/cco_report.sh (102), lib/cco_state.sh (149), lib/cco_wrapper.sh (120)", + "install_all deploys the libs in the same commit, so there is no window where the deployed copy is split but its libs are missing", + "Non-goal: shell coverage for the new libs; no test suite exists for this script" + ], + "changes": [ + "Split into four libs by concern: cco_state.sh (hour-bucket state files), cco_wrapper.sh (the launch gate), cco_install.sh (install/uninstall, the generated app wrapper and the pacman hook), cco_report.sh (status and help).", + "The entry script keeps its exact deployed path, /usr/local/bin/block-compulsive-opening.sh, and stays a real file there rather than a symlink. Already-installed app wrappers on disk hard-code that path, and the pacman hook at 96-restore-pkg-wrappers.hook invokes it; a symlink would also break dirname \"${BASH_SOURCE[0]}\" resolution.", + "_CCO_LIB_DIR probes for lib/ beside the script (repo tree) and falls back to the script's own directory (the flat /usr/local/bin deploy layout). Both branches are exercised below.", + "install_all copies the four libs flat into /usr/local/bin alongside the entry script, and uninstall_all removes them so no orphans are stranded. The copy runs even when the script is already at install_path, so a lib changed upstream still reaches the deployed copy that `rewrap-quiet` executes.", + "A guard before the source lines fails closed with an explicit message when any lib is unreadable, rather than dying on a bare `source` error.", + "Ran shfmt -w on the entry script, which removed a block of ~26 consecutive blank lines. No associative-array keys were affected: unlike install_leechblock.sh, this file's hyphenated keys (\"signal-desktop\") were already quoted, so the shfmt arithmetic-parsing bug does not apply here. Verified by grep after the run." + ], + "verification": [ + { + "command": "bash meta/scripts/check_file_length.sh --all", + "result": "pass", + "evidence": "Over-cap list went from 4 files to 3; block_compulsive_opening.sh no longer listed. All five files 182/238/149/120/102 lines, under 250." + }, + { + "command": "python3 line-multiset check of entry+4 libs against HEAD's block_compulsive_opening.sh", + "result": "pass", + "evidence": "MISSING functional lines: 0. Every non-comment, non-blank line from the pre-split file is accounted for across the five files." + }, + { + "command": "Heredoc body extraction and diff for all three heredocs", + "result": "pass", + "evidence": "This is the check verify_shell_split.sh cannot perform, because it cannot see inside a heredoc body. WRAPPER_EOF (5 lines, the generated app wrapper), HOOK_EOF (14 lines, the generated pacman hook) and EOF (26 lines, the help text) are each byte-identical between HEAD and the split. The two deployed artifacts this script generates are therefore provably unchanged." + }, + { + "command": "bash block_compulsive_opening.sh status vs /usr/local/bin/block-compulsive-opening.sh status", + "result": "pass", + "evidence": "Byte-identical output, both exit 0. The deployed copy is still the pre-split monolith and sha256-identical to HEAD, so this compares the split against the real pre-split behaviour rather than against itself. `help` differs only in the $0 invocation path, as expected." + }, + { + "command": "Flat-layout test: entry script + libs copied into one directory (the deployed shape)", + "result": "pass", + "evidence": "status runs correctly with exit 0, confirming the _CCO_LIB_DIR fallback branch works in the /usr/local/bin layout, not just the repo tree." + }, + { + "command": "Missing-lib guard: delete cco_report.sh from the flat layout and re-run", + "result": "pass", + "evidence": "Fails closed with 'Error: missing library cco_report.sh in ' and exit code 1 (measured directly, not through a pipeline). A partially-deployed install cannot look successful to the pacman hook." + }, + { + "command": "shellcheck (no -x) + shfmt idempotence + bash -n on all five files", + "result": "pass", + "evidence": "shellcheck rc=0 with no suppressions; shfmt -w is a no-op on all five, so the pre-commit auto-format-and-re-stage cannot alter them; all five parse." + } + ], + "risks": [ + "The deployed copy at /usr/local/bin/block-compulsive-opening.sh is still the pre-split monolith and keeps working untouched; nothing breaks until someone runs `sudo block-compulsive-opening.sh install`, which now deploys the libs too. Verified that the pacman hook's `rewrap-quiet` path does not call install_all, so the hook cannot trigger a partial deploy.", + "The libs deploy flat into /usr/local/bin rather than into /usr/local/share/digital_wellbeing/lib. That is deliberate: install_leechblock_payload does an `rm -rf` mirror of the latter, which would delete these libs on the next pacman-wrapper install.", + "No test suite exists for this script, so coverage is 0% and the verification above is behavioural and textual rather than test-driven." + ], + "rollback": [ + "git revert this commit; block_compulsive_opening.sh returns to a single 705-line file and lib/cco_*.sh disappear.", + "If the split had already been deployed, re-run `sudo block-compulsive-opening.sh install` from the reverted tree to restore the monolith and remove the deployed libs.", + "Validate with: block-compulsive-opening.sh status (expect exit 0 and the per-app status table)." + ] +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh index 3071ff2b..5fe9ff00 100755 --- a/linux_configuration/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh @@ -9,6 +9,48 @@ set -euo pipefail +# The phases live in four libs. They are found in TWO layouts, because this +# script runs from both: +# +# repo: /lib/cco_*.sh +# deployed: /usr/local/bin/cco_*.sh (flat, beside the entry script) +# +# install_all copies the libs flat next to the installed entry script rather +# than creating /usr/local/bin/lib, matching how pacman_lock_lib.sh and +# heavy_job_lock.sh are already deployed there. +# +# readlink -f because /usr/bin/ wrappers exec this through its absolute +# path and the installed copy may itself be reached via a symlink. +# +# This fails CLOSED: a missing lib aborts. The pacman fail-open pattern is +# wrong here — a half-loaded blocker that silently stops blocking is the exact +# failure this tool exists to prevent, and unlike pacman you do not need this +# script to repair it. +_CCO_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" +if [[ -d "$_CCO_DIR/lib" ]]; then + _CCO_LIB_DIR="$_CCO_DIR/lib" +else + _CCO_LIB_DIR="$_CCO_DIR" +fi + +for _cco_lib in cco_state cco_wrapper cco_install cco_report; do + if [[ ! -r "$_CCO_LIB_DIR/${_cco_lib}.sh" ]]; then + echo "Error: missing library ${_cco_lib}.sh in $_CCO_LIB_DIR" >&2 + echo "The installation is incomplete; re-run: sudo $0 install" >&2 + exit 1 + fi +done +unset _cco_lib + +# shellcheck source=lib/cco_state.sh +. "$_CCO_LIB_DIR/cco_state.sh" +# shellcheck source=lib/cco_wrapper.sh +. "$_CCO_LIB_DIR/cco_wrapper.sh" +# shellcheck source=lib/cco_install.sh +. "$_CCO_LIB_DIR/cco_install.sh" +# shellcheck source=lib/cco_report.sh +. "$_CCO_LIB_DIR/cco_report.sh" + # Send desktop notification (inlined from common.sh to avoid dependency issues # when script is installed to /usr/local/bin) notify() { @@ -79,571 +121,6 @@ declare -A PROCESS_MATCH=( ["discord"]="$_cco_home/.config/discord/" ) -# Ensure state directory exists -ensure_state_dir() { - mkdir -p "$STATE_DIR" 2>/dev/null || true -} - -# Log message with timestamp -log_message() { - local msg - msg="$(printf '%(%Y-%m-%d %H:%M:%S)T' -1) - $1" - echo "$msg" >&2 - echo "$msg" >>"$LOG_FILE" 2>/dev/null || true -} - -# Get current hour key (YYYY-MM-DD-HH format) -get_hour_key() { - printf '%(%Y-%m-%d-%H)T' -1 -} - -# Get state file path for an app -get_state_file() { - local app="$1" - echo "$STATE_DIR/${app}.lastopen" -} - -# Check if app was already opened this hour -was_opened_this_hour() { - local app="$1" - local state_file - state_file=$(get_state_file "$app") - local current_hour - current_hour=$(get_hour_key) - - if [[ -f $state_file ]]; then - local last_hour - last_hour=$(cat "$state_file" 2>/dev/null || echo "") - if [[ $last_hour == "$current_hour" ]]; then - return 0 # Was opened this hour - fi - fi - return 1 # Not opened this hour -} - -# Record app opening -record_opening() { - local app="$1" - local state_file - state_file=$(get_state_file "$app") - local current_hour - current_hour=$(get_hour_key) - - echo "$current_hour" >"$state_file" - log_message "ALLOWED: $app opened (first time this hour: $current_hour)" -} - -# Block app and notify -block_app() { - local app="$1" - local current_hour - current_hour=$(get_hour_key) - - log_message "BLOCKED: $app launch prevented (already opened this hour: $current_hour)" - - # Send notification using common library - notify "🚫 $app Blocked" "Already opened this hour. Wait until the next hour." critical 5000 -} - -# Get real binary path for an app -get_real_binary() { - local app="$1" - local wrapper_path="${APPS[$app]}" - local real_binary="${REAL_BINARIES[$app]}" - - # Check if wrapper is installed (original moved to .orig) - if [[ -f "${wrapper_path}.orig" ]]; then - # Wrapper installed, return the actual executable - echo "$real_binary" - return 0 - fi - - return 1 -} - -# Get running state file path for an app (tracks PID and start time) -get_running_file() { - local app="$1" - echo "$STATE_DIR/${app}.running" -} - -# Clean up stale running state (process no longer running) -# Uses process-name matching so Electron apps that fork don't appear stale. -cleanup_stale_running_state() { - local app="$1" - local running_file - running_file=$(get_running_file "$app") - - if [[ ! -f $running_file ]]; then - return 0 - fi - - # Match on PROCESS_MATCH when the exec target is a launcher that exec()s away - # (see the PROCESS_MATCH map); otherwise the exec target is the process. - local real_binary="${PROCESS_MATCH[$app]:-${REAL_BINARIES[$app]}}" - - # Check if any process matching the real binary is still running - if ! is_app_running "$real_binary"; then - log_message "CLEANUP: Stale running state for $app (no matching processes found)" - rm -f "$running_file" - fi -} - -# Check if app is running by process name (handles Electron apps that fork) -is_app_running() { - local real_binary="$1" - pgrep -f "$real_binary" >/dev/null 2>&1 -} - -# Kill all processes matching the real binary path -kill_app() { - local real_binary="$1" - pkill -f "$real_binary" 2>/dev/null || true - sleep 2 - pkill -9 -f "$real_binary" 2>/dev/null || true -} - -# Check if auto-close is suspended for an app today -is_autoclose_suspended() { - local app="$1" - local today - today=$(printf '%(%Y-%m-%d)T' -1) - local suspend_file="$STATE_DIR/${app}.suspend-autoclose" - - if [[ -f $suspend_file ]]; then - local suspend_date - suspend_date=$(cat "$suspend_file" 2>/dev/null || echo "") - if [[ $suspend_date == "$today" ]]; then - return 0 # Suspended for today - else - # Stale suspend file, clean up - rm -f "$suspend_file" - fi - fi - return 1 -} - -# Launch app with auto-close timer -launch_with_timer() { - local app="$1" - local real_binary="$2" - shift 2 - - # $real_binary is what we EXEC; $proc_match is what we look for in the process - # table. They are usually identical, but NOT when the exec target is a launcher - # that exec()s away: discord's /usr/bin/discord.orig replaces itself with - # ~/.config/discord//Discord, so matching/killing on the launcher path - # would find nothing — the auto-close daemon would think the app exited - # instantly and the time limit would silently never fire. - local proc_match="${PROCESS_MATCH[$app]:-$real_binary}" - - # Check if auto-close is suspended for today - if is_autoclose_suspended "$app"; then - log_message "LAUNCHED: $app (auto-close suspended for today)" - exec "$real_binary" "$@" - fi - - # Use per-app timeout if set, otherwise fall back to global default - local timeout_minutes="${APP_TIMEOUT_MINUTES[$app]:-$AUTO_CLOSE_TIMEOUT_MINUTES}" - local warning_seconds=$(((timeout_minutes - AUTO_CLOSE_WARNING_MINUTES) * 60)) - local running_file - running_file=$(get_running_file "$app") - - # Launch the app in background - "$real_binary" "$@" & - local app_pid=$! - - # Give Electron apps time to fork before we start polling - sleep 2 - - # Record state (FORK-FREE: use printf %s for timestamp) - echo "$app_pid $(printf '%(%s)T' -1)" >"$running_file" - log_message "LAUNCHED: $app with PID $app_pid (auto-close in ${timeout_minutes}m)" - - # Spawn the auto-close daemon in a completely detached subshell - # Uses process-name matching so it works for Electron apps that fork on launch - ( - # Detach from terminal - exec /dev/null 2>&1 - - # Wait for warning time - sleep "$warning_seconds" - - # Check if still running before warning (by process name, not PID) - if is_app_running "$proc_match"; then - # Send warning notification - notify-send -u critical -t 30000 "⏰ $app Closing Soon" \ - "Session will end in ${AUTO_CLOSE_WARNING_MINUTES} minutes. Save your work!" 2>/dev/null || true - else - # Process already exited - rm -f "$running_file" 2>/dev/null || true - exit 0 - fi - - # Wait remaining time - sleep $((AUTO_CLOSE_WARNING_MINUTES * 60)) - - # Check if still running (by process name) - if is_app_running "$proc_match"; then - # Send final notification - notify-send -u critical -t 5000 "🚫 $app Session Ended" \ - "Time's up! Closing $app now." 2>/dev/null || true - - # Kill all matching processes (handles forked Electron children) - kill_app "$proc_match" - - printf '%(%Y-%m-%d %H:%M:%S)T - AUTO-CLOSED: %s after %dm\n' -1 "$app" "${timeout_minutes}" >>"$LOG_FILE" 2>/dev/null || true - fi - - rm -f "$running_file" 2>/dev/null || true - ) & - disown - - # Wait for the app to exit by polling process name. - # Electron apps fork immediately so waiting on $app_pid would return too soon. - while is_app_running "$proc_match"; do - sleep 5 - done - - # Clean up running state - rm -f "$running_file" 2>/dev/null || true - - log_message "EXITED: $app" -} - -# Main wrapper function - called when wrapping app launches -wrapper_main() { - local app="$1" - shift - - ensure_state_dir - - local real_binary - if ! real_binary=$(get_real_binary "$app"); then - log_message "ERROR: Real binary not found for $app" - echo "Error: Real binary for $app not found. Was the installer run?" >&2 - exit 1 - fi - - # Clean up stale running state from previous crashes - cleanup_stale_running_state "$app" - - if was_opened_this_hour "$app"; then - block_app "$app" - exit 1 - fi - - record_opening "$app" - - # Launch with auto-close timer (replaces direct exec) - launch_with_timer "$app" "$real_binary" "$@" -} - -# Install wrapper for a specific app -install_wrapper() { - local app="$1" - local wrapper_path="${APPS[$app]}" - local real_binary="${REAL_BINARIES[$app]}" - - # Check if already wrapped: .orig must exist AND current file must be our wrapper - if [[ -f "${wrapper_path}.orig" ]]; then - if grep -q "block-compulsive-opening" "$wrapper_path" 2>/dev/null; then - echo " ✓ $app already wrapped" - return 0 - else - # .orig exists but wrapper was overwritten (e.g. by package update) - echo " ↻ $app wrapper was overwritten, re-installing..." - rm -f "${wrapper_path}.orig" - # Fall through to re-install - fi - fi - - # Check if wrapper location exists (file or symlink) - if [[ ! -e $wrapper_path && ! -L $wrapper_path ]]; then - echo " ⚠ $app not installed ($wrapper_path not found)" - return 1 - fi - - # Check if real binary exists. - # Special case: when the exec target IS this wrapper's own backup - # ("${wrapper_path}.orig") the app has no separate system binary — the - # launcher itself is the real thing, and the wrap below CREATES the .orig by - # moving it aside. Requiring it to exist beforehand is a chicken-and-egg that - # could never pass. The wrapper_path check above already proved it installed. - if [[ $real_binary != "${wrapper_path}.orig" && ! -x $real_binary ]]; then - echo " ⚠ $app real binary not found ($real_binary)" - return 1 - fi - - echo " Installing wrapper for $app..." - - # Handle symlinks: save the symlink itself, not the target - if [[ -L $wrapper_path ]]; then - local link_target - link_target=$(readlink "$wrapper_path") - echo " Saving symlink $wrapper_path -> $link_target as ${wrapper_path}.orig" - # Remove symlink and create .orig that stores the link target info - echo "SYMLINK:$link_target" >"${wrapper_path}.orig" - rm "$wrapper_path" - else - echo " Backing up $wrapper_path -> ${wrapper_path}.orig" - mv "$wrapper_path" "${wrapper_path}.orig" - fi - - echo " Creating wrapper at $wrapper_path" - cat >"$wrapper_path" </dev/null || echo "") - if [[ $orig_content == SYMLINK:* ]]; then - local link_target="${orig_content#SYMLINK:}" - echo " Restoring symlink $wrapper_path -> $link_target" - ln -s "$link_target" "$wrapper_path" - rm "${wrapper_path}.orig" - else - echo " Restoring original file" - mv "${wrapper_path}.orig" "$wrapper_path" - fi - echo " ✓ $app restored" -} - -# Install all wrappers -install_all() { - echo "Installing compulsive opening blockers..." - echo "" - - # Install main script to /usr/local/bin - local script_path - script_path="$(readlink -f "$0")" - local install_path="/usr/local/bin/block-compulsive-opening.sh" - - if [[ $script_path != "$install_path" ]]; then - echo "Installing main script to $install_path..." - cp "$script_path" "$install_path" - chmod +x "$install_path" - echo "✓ Main script installed" - else - echo "Main script already at $install_path" - fi - echo "" - - # Install wrappers for each app - local installed=0 - for app in "${!APPS[@]}"; do - if install_wrapper "$app"; then - ((installed++)) || true - fi - done - - echo "" - echo "Installation complete. $installed app(s) wrapped." - echo "" - echo "Each app can now only be opened once per hour." - echo "State files stored in: $STATE_DIR" - echo "Logs stored in: $LOG_FILE" - - # Install pacman hook to re-wrap after package updates - install_pacman_hook -} - -# Install pacman hook to re-install wrappers after package updates -install_pacman_hook() { - local hook_dir="/etc/pacman.d/hooks" - local hook_file="$hook_dir/95-compulsive-block-rewrap.hook" - - echo "" - echo "Installing pacman hook..." - - mkdir -p "$hook_dir" - - cat >"$hook_file" <<'HOOK_EOF' -[Trigger] -Operation = Upgrade -Operation = Install -Type = Package -Target = beeper -Target = signal-desktop -Target = discord - -[Action] -Description = Re-installing compulsive opening blockers after package update -When = PostTransaction -Exec = /usr/local/bin/block-compulsive-opening.sh rewrap-quiet -HOOK_EOF - - chmod 644 "$hook_file" - echo "✓ Pacman hook installed: $hook_file" - echo " Wrappers will be automatically re-installed after beeper/signal/discord updates" -} - -# Uninstall pacman hook -uninstall_pacman_hook() { - local hook_file="/etc/pacman.d/hooks/95-compulsive-block-rewrap.hook" - if [[ -f $hook_file ]]; then - rm -f "$hook_file" - echo "✓ Pacman hook removed" - fi -} - -# Quietly re-wrap apps (for pacman hook - no interactive output) -rewrap_quiet() { - log_message "REWRAP: Pacman hook triggered, re-installing wrappers" - - for app in "${!APPS[@]}"; do - local wrapper_path="${APPS[$app]}" - - # Re-wrap if wrapper is missing or was overwritten by a package update - if [[ ! -f "${wrapper_path}.orig" ]] || ! grep -q "block-compulsive-opening" "$wrapper_path" 2>/dev/null; then - log_message "REWRAP: $app wrapper missing or overwritten, re-installing" - rm -f "${wrapper_path}.orig" - install_wrapper "$app" >>"$LOG_FILE" 2>&1 || true - fi - done - - log_message "REWRAP: Complete" -} - -# Uninstall all wrappers -uninstall_all() { - echo "Removing compulsive opening blockers..." - echo "" - - for app in "${!APPS[@]}"; do - uninstall_wrapper "$app" || true - done - - rm -f "/usr/local/bin/block-compulsive-opening.sh" - - # Remove pacman hook - uninstall_pacman_hook - - echo "" - echo "Uninstallation complete." -} - -# Show status of all apps -show_status() { - ensure_state_dir - local current_hour - current_hour=$(get_hour_key) - - echo "Compulsive Opening Blocker Status" - echo "==================================" - echo "Current hour: $current_hour" - echo "" - - for app in "${!APPS[@]}"; do - local state_file - state_file=$(get_state_file "$app") - local status="not opened this hour" - local icon="○" - - if [[ -f $state_file ]]; then - local last_hour - last_hour=$(cat "$state_file" 2>/dev/null || echo "") - if [[ $last_hour == "$current_hour" ]]; then - status="already opened (blocked until next hour)" - icon="●" - else - status="last opened: $last_hour" - fi - fi - - # Check if wrapped - local wrapped="not installed" - local wrapper_path="${APPS[$app]}" - if [[ -f "${wrapper_path}.orig" ]]; then - wrapped="wrapped" - elif [[ -f $wrapper_path ]]; then - wrapped="installed (not wrapped)" - fi - - printf " %s %-15s [%s] - %s\n" "$icon" "$app" "$wrapped" "$status" - done - - echo "" - echo "State directory: $STATE_DIR" -} - -# Reset state for an app (allow opening again) -reset_app() { - local app="$1" - local state_file - state_file=$(get_state_file "$app") - - if [[ -f $state_file ]]; then - rm -f "$state_file" - echo "Reset $app - can be opened again this hour" - log_message "RESET: $app state cleared by user" - else - echo "$app was not marked as opened" - fi -} - -# Clear all state -reset_all() { - ensure_state_dir - rm -f "$STATE_DIR"/*.lastopen - echo "All apps reset - can be opened again this hour" - log_message "RESET: All app states cleared by user" -} - -# Show usage -show_usage() { - cat < - Reset an app to allow opening again this hour - reset-all - Reset all apps - wrapper [args] - Run as wrapper for an app (internal use) - help - Show this help message - -Managed Apps: - beeper - Beeper messaging client - signal-desktop - Signal messenger - discord - Discord chat - -Examples: - sudo $0 install # Install all wrappers - $0 status # Check which apps were opened this hour - $0 reset discord # Allow Discord to be opened again - -EOF -} - # Main entry point main() { case "${1:-help}" in diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_install.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_install.sh new file mode 100755 index 00000000..4550bb03 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_install.sh @@ -0,0 +1,238 @@ +#!/bin/bash +# Install/uninstall of the per-app wrappers, the flat lib deployment and the +# pacman rewrap hook for block_compulsive_opening.sh. +# Sourced by the entry script; inherits its strict mode and globals. + +# Install wrapper for a specific app +install_wrapper() { + local app="$1" + local wrapper_path="${APPS[$app]}" + local real_binary="${REAL_BINARIES[$app]}" + + # Check if already wrapped: .orig must exist AND current file must be our wrapper + if [[ -f "${wrapper_path}.orig" ]]; then + if grep -q "block-compulsive-opening" "$wrapper_path" 2>/dev/null; then + echo " ✓ $app already wrapped" + return 0 + else + # .orig exists but wrapper was overwritten (e.g. by package update) + echo " ↻ $app wrapper was overwritten, re-installing..." + rm -f "${wrapper_path}.orig" + # Fall through to re-install + fi + fi + + # Check if wrapper location exists (file or symlink) + if [[ ! -e $wrapper_path && ! -L $wrapper_path ]]; then + echo " ⚠ $app not installed ($wrapper_path not found)" + return 1 + fi + + # Check if real binary exists. + # Special case: when the exec target IS this wrapper's own backup + # ("${wrapper_path}.orig") the app has no separate system binary — the + # launcher itself is the real thing, and the wrap below CREATES the .orig by + # moving it aside. Requiring it to exist beforehand is a chicken-and-egg that + # could never pass. The wrapper_path check above already proved it installed. + if [[ $real_binary != "${wrapper_path}.orig" && ! -x $real_binary ]]; then + echo " ⚠ $app real binary not found ($real_binary)" + return 1 + fi + + echo " Installing wrapper for $app..." + + # Handle symlinks: save the symlink itself, not the target + if [[ -L $wrapper_path ]]; then + local link_target + link_target=$(readlink "$wrapper_path") + echo " Saving symlink $wrapper_path -> $link_target as ${wrapper_path}.orig" + # Remove symlink and create .orig that stores the link target info + echo "SYMLINK:$link_target" >"${wrapper_path}.orig" + rm "$wrapper_path" + else + echo " Backing up $wrapper_path -> ${wrapper_path}.orig" + mv "$wrapper_path" "${wrapper_path}.orig" + fi + + echo " Creating wrapper at $wrapper_path" + cat >"$wrapper_path" </dev/null || echo "") + if [[ $orig_content == SYMLINK:* ]]; then + local link_target="${orig_content#SYMLINK:}" + echo " Restoring symlink $wrapper_path -> $link_target" + ln -s "$link_target" "$wrapper_path" + rm "${wrapper_path}.orig" + else + echo " Restoring original file" + mv "${wrapper_path}.orig" "$wrapper_path" + fi + echo " ✓ $app restored" +} + +# Install all wrappers +install_all() { + echo "Installing compulsive opening blockers..." + echo "" + + # Install main script to /usr/local/bin + local script_path + script_path="$(readlink -f "$0")" + local install_path="/usr/local/bin/block-compulsive-opening.sh" + + if [[ $script_path != "$install_path" ]]; then + echo "Installing main script to $install_path..." + cp "$script_path" "$install_path" + chmod +x "$install_path" + echo "✓ Main script installed" + else + echo "Main script already at $install_path" + fi + + # The libs travel with the entry script, FLAT beside it rather than in a + # lib/ subdirectory — the same shape as /usr/local/bin/pacman_lock_lib.sh + # and heavy_job_lock.sh. The entry script probes for lib/ first and falls + # back to its own directory, so both layouts work. + # + # This must run even when the script is already at $install_path: a lib + # added or changed upstream still has to reach /usr/local/bin, and + # `rewrap-quiet` from the pacman hook runs the DEPLOYED copy, which would + # otherwise keep sourcing stale libs. + local lib_src="$_CCO_LIB_DIR" + local lib_dest="/usr/local/bin" + if [[ $lib_src != "$lib_dest" ]]; then + echo "Installing libraries to $lib_dest..." + local lib + for lib in cco_state cco_wrapper cco_install cco_report; do + cp "$lib_src/${lib}.sh" "$lib_dest/${lib}.sh" + chmod +x "$lib_dest/${lib}.sh" + done + echo "✓ Libraries installed" + fi + echo "" + + # Install wrappers for each app + local installed=0 + for app in "${!APPS[@]}"; do + if install_wrapper "$app"; then + ((installed++)) || true + fi + done + + echo "" + echo "Installation complete. $installed app(s) wrapped." + echo "" + echo "Each app can now only be opened once per hour." + echo "State files stored in: $STATE_DIR" + echo "Logs stored in: $LOG_FILE" + + # Install pacman hook to re-wrap after package updates + install_pacman_hook +} + +# Install pacman hook to re-install wrappers after package updates +install_pacman_hook() { + local hook_dir="/etc/pacman.d/hooks" + local hook_file="$hook_dir/95-compulsive-block-rewrap.hook" + + echo "" + echo "Installing pacman hook..." + + mkdir -p "$hook_dir" + + cat >"$hook_file" <<'HOOK_EOF' +[Trigger] +Operation = Upgrade +Operation = Install +Type = Package +Target = beeper +Target = signal-desktop +Target = discord + +[Action] +Description = Re-installing compulsive opening blockers after package update +When = PostTransaction +Exec = /usr/local/bin/block-compulsive-opening.sh rewrap-quiet +HOOK_EOF + + chmod 644 "$hook_file" + echo "✓ Pacman hook installed: $hook_file" + echo " Wrappers will be automatically re-installed after beeper/signal/discord updates" +} + +# Uninstall pacman hook +uninstall_pacman_hook() { + local hook_file="/etc/pacman.d/hooks/95-compulsive-block-rewrap.hook" + if [[ -f $hook_file ]]; then + rm -f "$hook_file" + echo "✓ Pacman hook removed" + fi +} + +# Quietly re-wrap apps (for pacman hook - no interactive output) +rewrap_quiet() { + log_message "REWRAP: Pacman hook triggered, re-installing wrappers" + + for app in "${!APPS[@]}"; do + local wrapper_path="${APPS[$app]}" + + # Re-wrap if wrapper is missing or was overwritten by a package update + if [[ ! -f "${wrapper_path}.orig" ]] || ! grep -q "block-compulsive-opening" "$wrapper_path" 2>/dev/null; then + log_message "REWRAP: $app wrapper missing or overwritten, re-installing" + rm -f "${wrapper_path}.orig" + install_wrapper "$app" >>"$LOG_FILE" 2>&1 || true + fi + done + + log_message "REWRAP: Complete" +} + +# Uninstall all wrappers +uninstall_all() { + echo "Removing compulsive opening blockers..." + echo "" + + for app in "${!APPS[@]}"; do + uninstall_wrapper "$app" || true + done + + rm -f "/usr/local/bin/block-compulsive-opening.sh" + + # The libs install_all copied flat beside the entry script go with it; + # leaving them behind would strand four orphans in /usr/local/bin. + local lib + for lib in cco_state cco_wrapper cco_install cco_report; do + rm -f "/usr/local/bin/${lib}.sh" + done + + # Remove pacman hook + uninstall_pacman_hook + + echo "" + echo "Uninstallation complete." +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_report.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_report.sh new file mode 100755 index 00000000..046932ee --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_report.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# Status reporting and reset commands for block_compulsive_opening.sh. +# Sourced by the entry script; inherits its strict mode and globals. + +# Show status of all apps +show_status() { + ensure_state_dir + local current_hour + current_hour=$(get_hour_key) + + echo "Compulsive Opening Blocker Status" + echo "==================================" + echo "Current hour: $current_hour" + echo "" + + for app in "${!APPS[@]}"; do + local state_file + state_file=$(get_state_file "$app") + local status="not opened this hour" + local icon="○" + + if [[ -f $state_file ]]; then + local last_hour + last_hour=$(cat "$state_file" 2>/dev/null || echo "") + if [[ $last_hour == "$current_hour" ]]; then + status="already opened (blocked until next hour)" + icon="●" + else + status="last opened: $last_hour" + fi + fi + + # Check if wrapped + local wrapped="not installed" + local wrapper_path="${APPS[$app]}" + if [[ -f "${wrapper_path}.orig" ]]; then + wrapped="wrapped" + elif [[ -f $wrapper_path ]]; then + wrapped="installed (not wrapped)" + fi + + printf " %s %-15s [%s] - %s\n" "$icon" "$app" "$wrapped" "$status" + done + + echo "" + echo "State directory: $STATE_DIR" +} + +# Reset state for an app (allow opening again) +reset_app() { + local app="$1" + local state_file + state_file=$(get_state_file "$app") + + if [[ -f $state_file ]]; then + rm -f "$state_file" + echo "Reset $app - can be opened again this hour" + log_message "RESET: $app state cleared by user" + else + echo "$app was not marked as opened" + fi +} + +# Clear all state +reset_all() { + ensure_state_dir + rm -f "$STATE_DIR"/*.lastopen + echo "All apps reset - can be opened again this hour" + log_message "RESET: All app states cleared by user" +} + +# Show usage +show_usage() { + cat < - Reset an app to allow opening again this hour + reset-all - Reset all apps + wrapper [args] - Run as wrapper for an app (internal use) + help - Show this help message + +Managed Apps: + beeper - Beeper messaging client + signal-desktop - Signal messenger + discord - Discord chat + +Examples: + sudo $0 install # Install all wrappers + $0 status # Check which apps were opened this hour + $0 reset discord # Allow Discord to be opened again + +EOF +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_state.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_state.sh new file mode 100755 index 00000000..d01a6e97 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_state.sh @@ -0,0 +1,149 @@ +#!/bin/bash +# State, timing and process helpers for block_compulsive_opening.sh. +# Sourced by the entry script; inherits its strict mode, its notify() helper +# and the STATE_DIR/LOG_FILE/APPS/REAL_BINARIES/PROCESS_MATCH globals it +# defines. + +# Ensure state directory exists +ensure_state_dir() { + mkdir -p "$STATE_DIR" 2>/dev/null || true +} + +# Log message with timestamp +log_message() { + local msg + msg="$(printf '%(%Y-%m-%d %H:%M:%S)T' -1) - $1" + echo "$msg" >&2 + echo "$msg" >>"$LOG_FILE" 2>/dev/null || true +} + +# Get current hour key (YYYY-MM-DD-HH format) +get_hour_key() { + printf '%(%Y-%m-%d-%H)T' -1 +} + +# Get state file path for an app +get_state_file() { + local app="$1" + echo "$STATE_DIR/${app}.lastopen" +} + +# Check if app was already opened this hour +was_opened_this_hour() { + local app="$1" + local state_file + state_file=$(get_state_file "$app") + local current_hour + current_hour=$(get_hour_key) + + if [[ -f $state_file ]]; then + local last_hour + last_hour=$(cat "$state_file" 2>/dev/null || echo "") + if [[ $last_hour == "$current_hour" ]]; then + return 0 # Was opened this hour + fi + fi + return 1 # Not opened this hour +} + +# Record app opening +record_opening() { + local app="$1" + local state_file + state_file=$(get_state_file "$app") + local current_hour + current_hour=$(get_hour_key) + + echo "$current_hour" >"$state_file" + log_message "ALLOWED: $app opened (first time this hour: $current_hour)" +} + +# Block app and notify +block_app() { + local app="$1" + local current_hour + current_hour=$(get_hour_key) + + log_message "BLOCKED: $app launch prevented (already opened this hour: $current_hour)" + + # Send notification using common library + notify "🚫 $app Blocked" "Already opened this hour. Wait until the next hour." critical 5000 +} + +# Get real binary path for an app +get_real_binary() { + local app="$1" + local wrapper_path="${APPS[$app]}" + local real_binary="${REAL_BINARIES[$app]}" + + # Check if wrapper is installed (original moved to .orig) + if [[ -f "${wrapper_path}.orig" ]]; then + # Wrapper installed, return the actual executable + echo "$real_binary" + return 0 + fi + + return 1 +} + +# Get running state file path for an app (tracks PID and start time) +get_running_file() { + local app="$1" + echo "$STATE_DIR/${app}.running" +} + +# Clean up stale running state (process no longer running) +# Uses process-name matching so Electron apps that fork don't appear stale. +cleanup_stale_running_state() { + local app="$1" + local running_file + running_file=$(get_running_file "$app") + + if [[ ! -f $running_file ]]; then + return 0 + fi + + # Match on PROCESS_MATCH when the exec target is a launcher that exec()s away + # (see the PROCESS_MATCH map); otherwise the exec target is the process. + local real_binary="${PROCESS_MATCH[$app]:-${REAL_BINARIES[$app]}}" + + # Check if any process matching the real binary is still running + if ! is_app_running "$real_binary"; then + log_message "CLEANUP: Stale running state for $app (no matching processes found)" + rm -f "$running_file" + fi +} + +# Check if app is running by process name (handles Electron apps that fork) +is_app_running() { + local real_binary="$1" + pgrep -f "$real_binary" >/dev/null 2>&1 +} + +# Kill all processes matching the real binary path +kill_app() { + local real_binary="$1" + pkill -f "$real_binary" 2>/dev/null || true + sleep 2 + pkill -9 -f "$real_binary" 2>/dev/null || true +} + +# Check if auto-close is suspended for an app today +is_autoclose_suspended() { + local app="$1" + local today + today=$(printf '%(%Y-%m-%d)T' -1) + local suspend_file="$STATE_DIR/${app}.suspend-autoclose" + + if [[ -f $suspend_file ]]; then + local suspend_date + suspend_date=$(cat "$suspend_file" 2>/dev/null || echo "") + if [[ $suspend_date == "$today" ]]; then + return 0 # Suspended for today + else + # Stale suspend file, clean up + rm -f "$suspend_file" + fi + fi + return 1 +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_wrapper.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_wrapper.sh new file mode 100755 index 00000000..2181d838 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_wrapper.sh @@ -0,0 +1,120 @@ +#!/bin/bash +# App-launch path for block_compulsive_opening.sh: the auto-close timer and the +# wrapper entry that the generated /usr/bin/ shims exec into. +# Sourced by the entry script; inherits its strict mode and globals. + +# Launch app with auto-close timer +launch_with_timer() { + local app="$1" + local real_binary="$2" + shift 2 + + # $real_binary is what we EXEC; $proc_match is what we look for in the process + # table. They are usually identical, but NOT when the exec target is a launcher + # that exec()s away: discord's /usr/bin/discord.orig replaces itself with + # ~/.config/discord//Discord, so matching/killing on the launcher path + # would find nothing — the auto-close daemon would think the app exited + # instantly and the time limit would silently never fire. + local proc_match="${PROCESS_MATCH[$app]:-$real_binary}" + + # Check if auto-close is suspended for today + if is_autoclose_suspended "$app"; then + log_message "LAUNCHED: $app (auto-close suspended for today)" + exec "$real_binary" "$@" + fi + + # Use per-app timeout if set, otherwise fall back to global default + local timeout_minutes="${APP_TIMEOUT_MINUTES[$app]:-$AUTO_CLOSE_TIMEOUT_MINUTES}" + local warning_seconds=$(((timeout_minutes - AUTO_CLOSE_WARNING_MINUTES) * 60)) + local running_file + running_file=$(get_running_file "$app") + + # Launch the app in background + "$real_binary" "$@" & + local app_pid=$! + + # Give Electron apps time to fork before we start polling + sleep 2 + + # Record state (FORK-FREE: use printf %s for timestamp) + echo "$app_pid $(printf '%(%s)T' -1)" >"$running_file" + log_message "LAUNCHED: $app with PID $app_pid (auto-close in ${timeout_minutes}m)" + + # Spawn the auto-close daemon in a completely detached subshell + # Uses process-name matching so it works for Electron apps that fork on launch + ( + # Detach from terminal + exec /dev/null 2>&1 + + # Wait for warning time + sleep "$warning_seconds" + + # Check if still running before warning (by process name, not PID) + if is_app_running "$proc_match"; then + # Send warning notification + notify-send -u critical -t 30000 "⏰ $app Closing Soon" \ + "Session will end in ${AUTO_CLOSE_WARNING_MINUTES} minutes. Save your work!" 2>/dev/null || true + else + # Process already exited + rm -f "$running_file" 2>/dev/null || true + exit 0 + fi + + # Wait remaining time + sleep $((AUTO_CLOSE_WARNING_MINUTES * 60)) + + # Check if still running (by process name) + if is_app_running "$proc_match"; then + # Send final notification + notify-send -u critical -t 5000 "🚫 $app Session Ended" \ + "Time's up! Closing $app now." 2>/dev/null || true + + # Kill all matching processes (handles forked Electron children) + kill_app "$proc_match" + + printf '%(%Y-%m-%d %H:%M:%S)T - AUTO-CLOSED: %s after %dm\n' -1 "$app" "${timeout_minutes}" >>"$LOG_FILE" 2>/dev/null || true + fi + + rm -f "$running_file" 2>/dev/null || true + ) & + disown + + # Wait for the app to exit by polling process name. + # Electron apps fork immediately so waiting on $app_pid would return too soon. + while is_app_running "$proc_match"; do + sleep 5 + done + + # Clean up running state + rm -f "$running_file" 2>/dev/null || true + + log_message "EXITED: $app" +} + +# Main wrapper function - called when wrapping app launches +wrapper_main() { + local app="$1" + shift + + ensure_state_dir + + local real_binary + if ! real_binary=$(get_real_binary "$app"); then + log_message "ERROR: Real binary not found for $app" + echo "Error: Real binary for $app not found. Was the installer run?" >&2 + exit 1 + fi + + # Clean up stale running state from previous crashes + cleanup_stale_running_state "$app" + + if was_opened_this_hour "$app"; then + block_app "$app" + exit 1 + fi + + record_opening "$app" + + # Launch with auto-close timer (replaces direct exec) + launch_with_timer "$app" "$real_binary" "$@" +}