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) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-21 15:51:06 +02:00
parent baeb1119d0
commit f5edebae4a
7 changed files with 735 additions and 565 deletions

View File

@ -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."
}

View File

@ -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 <dir>' 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)."
]
}

View File

@ -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: <here>/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/<app> 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/<app>/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 >/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" <<WRAPPER_EOF
#!/bin/bash
# Auto-generated wrapper for $app - blocks compulsive opening
# Real binary: $real_binary
# Original script: ${wrapper_path}.orig
exec /usr/local/bin/block-compulsive-opening.sh wrapper "$app" "\$@"
WRAPPER_EOF
chmod +x "$wrapper_path"
echo " ✓ $app wrapper installed"
}
# Uninstall wrapper for a specific app
uninstall_wrapper() {
local app="$1"
local wrapper_path="${APPS[$app]}"
if [[ ! -f "${wrapper_path}.orig" ]]; then
echo " ⚠ $app wrapper not found"
return 1
fi
echo " Removing wrapper for $app..."
rm -f "$wrapper_path"
# Check if it was a symlink (stored as SYMLINK:target in .orig)
local orig_content
orig_content=$(cat "${wrapper_path}.orig" 2>/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 <<EOF
Block Compulsive Opening Script
================================
Limits messaging apps to one launch per hour to reduce compulsive checking.
Usage: $0 [command] [args]
Commands:
install - Install wrappers for all apps (requires root)
uninstall - Remove all wrappers (requires root)
status - Show current status of all apps
reset <app> - Reset an app to allow opening again this hour
reset-all - Reset all apps
wrapper <app> [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

View File

@ -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" <<WRAPPER_EOF
#!/bin/bash
# Auto-generated wrapper for $app - blocks compulsive opening
# Real binary: $real_binary
# Original script: ${wrapper_path}.orig
exec /usr/local/bin/block-compulsive-opening.sh wrapper "$app" "\$@"
WRAPPER_EOF
chmod +x "$wrapper_path"
echo " ✓ $app wrapper installed"
}
# Uninstall wrapper for a specific app
uninstall_wrapper() {
local app="$1"
local wrapper_path="${APPS[$app]}"
if [[ ! -f "${wrapper_path}.orig" ]]; then
echo " ⚠ $app wrapper not found"
return 1
fi
echo " Removing wrapper for $app..."
rm -f "$wrapper_path"
# Check if it was a symlink (stored as SYMLINK:target in .orig)
local orig_content
orig_content=$(cat "${wrapper_path}.orig" 2>/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."
}

View File

@ -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 <<EOF
Block Compulsive Opening Script
================================
Limits messaging apps to one launch per hour to reduce compulsive checking.
Usage: $0 [command] [args]
Commands:
install - Install wrappers for all apps (requires root)
uninstall - Remove all wrappers (requires root)
status - Show current status of all apps
reset <app> - Reset an app to allow opening again this hour
reset-all - Reset all apps
wrapper <app> [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
}

View File

@ -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
}

View File

@ -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/<app> 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/<app>/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 >/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" "$@"
}