Extract digital-wellbeing; the directory-depth cap now enforces

The last unit over the cap is now github.com/kuhyx/digital-wellbeing, cloned
at ~/digital-wellbeing, and `--warn` is gone from the directory-depth-cap
hook. `check_directory_depth.sh --all` exits 0 with zero violations, down from
46 at the start of the session. That was the point of the campaign.

History was filtered across all three paths the unit has lived at (63
commits); the current path alone holds 2.

The six suites that test this code lived in linux_configuration/tests/ rather
than beside it, so they were filtered separately (24 commits) and merged in as
a second root. A path filter of the unit alone would have left them here,
testing code that had left the repo -- and their names would still have been
in shell-tests.yml's hardcoded arrays, failing CI on missing files.

linux_configuration/lib/common.sh is vendored into vendor/ rather than trimmed
to the four functions actually used: set_actual_user_vars calls two of the
others, so a trim breaks silently. It is in vendor/ and not lib/ because
test_music_parallelism.sh copies the unit's own lib/ into a scratch worktree
while stubbing the shared lib; in one directory the real common.sh overwrote
the stub, and the suite silently measured the wrong library.

Running the callers caught what shellcheck and grep both missed:
check_and_enable_services.sh died with "DW_REPO: unbound variable" because the
definition sat below its first use. It now runs clean and reports ok for every
extracted-unit check.

Two long-standing live defects fixed as a consequence. The pacman-wrapper
drift manifest embeds ABSOLUTE paths and had named deleted scripts/ paths
since the flatten, so the check reported a permanent false "stale or
tampered"; it now verifies, with unchanged hashes proving the extracted bytes
are identical. And the last two stale live references are gone: 8 -> 0.

The two pre-commit excludes for setup_midnight_shutdown.sh are dropped rather
than moved. lsattr shows nothing in the unit is chattr +i any more, and the
fixer hooks leave the file alone when allowed to run.

Removing 80 shell files also shrank the jscpd denominator enough that the
virtualenv/tqdm clones inside the GENERATED .ci-mirror-venv/ tipped the repo
to 2.09% and blocked this commit, which adds no duplication. That directory is
the same vendored third-party shell the ignore list already excludes as
.venv/, just under another name; jscpd walks the working tree rather than the
index, so being untracked did not spare it. Excluded by name: 1.74%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJrqKERAo5QUeEHWS5Asgp
This commit is contained in:
Krzysztof Rudnicki 2026-08-23 20:57:51 +02:00
parent 981823e3b9
commit 6b828d29aa
106 changed files with 182 additions and 187368 deletions

View File

@ -27,7 +27,6 @@ jobs:
set -euo pipefail set -euo pipefail
tests=( tests=(
test_i3blocks_persist_common.sh test_i3blocks_persist_common.sh
test_music_parallelism.sh
test_setup_dns_blocker.sh test_setup_dns_blocker.sh
) )
failed=0 failed=0
@ -124,7 +123,9 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# The complete Arch-dependent set. All eight verified locally in # The complete Arch-dependent set. Five of the original eight moved to
# github.com/kuhyx/digital-wellbeing with the code they test; they run
# bare on Ubuntu there. The two that remain were verified locally in
# `docker run --cap-add=SYS_PTRACE archlinux:base-devel`. # `docker run --cap-add=SYS_PTRACE archlinux:base-devel`.
# #
# test_security_hardening.sh inspects a configured machine (chattr flags, # test_security_hardening.sh inspects a configured machine (chattr flags,
@ -136,13 +137,8 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
tests=( tests=(
test_hosts_guard_pacman_integration.sh
test_i3blocks_efficiency.sh test_i3blocks_efficiency.sh
test_makepkg_capped.sh
test_makepkg_wrapper.sh
test_pacman_wrapper_security.sh
test_security_hardening.sh test_security_hardening.sh
test_shutdown_timer_monitor.sh
) )
failed=0 failed=0
for t in "${tests[@]}"; do for t in "${tests[@]}"; do

1
.gitignore vendored
View File

@ -381,7 +381,6 @@ bottles/
# archwiki_rag local vector index: regenerated by `reindex`, machine-specific, # archwiki_rag local vector index: regenerated by `reindex`, machine-specific,
# and full of binary sqlite/parquet blobs. # and full of binary sqlite/parquet blobs.
linux_configuration/periodic_background/digital_wellbeing/data/
# =========================================================================== # ===========================================================================
# billsplit (Flutter) — launcher icons & platform assets MUST live in-repo # billsplit (Flutter) — launcher icons & platform assets MUST live in-repo

View File

@ -34,12 +34,7 @@ repos:
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
args: [--markdown-linebreak-ext=md] args: [--markdown-linebreak-ext=md]
# chattr +i (self-commitment lock on the shutdown-enforcement
# script) makes this file unwritable on purpose; the fixer hooks
# can't open it for writing even as the owning user.
exclude: ^linux_configuration/periodic_background/digital_wellbeing/setup_midnight_shutdown\.sh$
- id: end-of-file-fixer - id: end-of-file-fixer
exclude: ^linux_configuration/periodic_background/digital_wellbeing/setup_midnight_shutdown\.sh$
- id: check-yaml - id: check-yaml
args: [--unsafe] args: [--unsafe]
- id: check-json - id: check-json
@ -87,15 +82,14 @@ repos:
pass_filenames: false pass_filenames: false
always_run: true always_run: true
- id: directory-depth-cap - id: directory-depth-cap
name: Report paths over the directory-depth cap name: Enforce the directory-depth cap
# Repo-wide, and deliberately --warn for now: 47 paths under # Repo-wide and ENFORCING. It ran --warn while the 46 paths under
# periodic_background/{hosts,digital_wellbeing} are over the cap and # periodic_background/{hosts,digital_wellbeing} were over the cap;
# can only come down by extracting those two units, which is blocked # both units are now their own repos (kuhyx/hosts-blocker,
# on a manual guard repoint. Running it warn-only keeps the number # kuhyx/digital-wellbeing) and the count is 0, so the cap is real.
# visible on every commit without blocking unrelated work. Drop # Re-adding --warn would silently re-open the hole it was there to
# --warn once both units are out; that is the last step of the # close: put the offending path somewhere legal instead.
# extraction, not a separate decision. entry: bash meta/scripts/check_directory_depth.sh --all
entry: bash meta/scripts/check_directory_depth.sh --warn --all
language: system language: system
pass_filenames: false pass_filenames: false
always_run: true always_run: true

View File

@ -42,8 +42,13 @@ Extracted to their own repos: [`steam-backlog-enforcer`](https://github.com/kuhy
[`phone-focus-mode`](https://github.com/kuhyx/phone-focus-mode) (with [`phone-focus-mode`](https://github.com/kuhyx/phone-focus-mode) (with
`python_pkg/focus_policy`), `python_pkg/focus_policy`),
[`hosts-blocker`](https://github.com/kuhyx/hosts-blocker) (lives at [`hosts-blocker`](https://github.com/kuhyx/hosts-blocker) (lives at
`~/hosts-blocker`; monorepo callers resolve it via `~/hosts-blocker`),
`linux_configuration/lib/extracted_repos.sh`). [`digital-wellbeing`](https://github.com/kuhyx/digital-wellbeing) (lives at
`~/digital-wellbeing`; carries the six suites that used to sit in
`linux_configuration/tests/`). Monorepo callers resolve both via
`linux_configuration/lib/extracted_repos.sh`, which reads the invoking user's
home from `SUDO_USER` — never `$HOME`, which is `/root` under sudo and under
systemd.
Archived work: [`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive). Archived work: [`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive).
## Adding a Flutter app ## Adding a Flutter app

View File

@ -0,0 +1,22 @@
{
"title": "Extract digital_wellbeing, move its six test suites with it, and make the directory-depth cap enforce",
"objective": "Clear the last 41 directory-depth violations by moving the unit to github.com/kuhyx/digital-wellbeing, then delete --warn from the depth hook so the cap is real -- the point of the whole campaign.",
"acceptance_criteria": [
"check_directory_depth.sh --all exits 0 with --warn removed from the hook entry.",
"The extracted repo's history spans all three paths the unit has lived at (63 commits), plus the 24 from the separately-filtered test suites.",
"All 80 tracked files are byte-identical to the monorepo copies; node_modules and __pycache__ are excluded.",
"All six moved suites plus every lib/tests/run_all.sh pass from ~/digital-wellbeing, with no root, pacman or Arch userland -- verified under env -i.",
"Every moved test name is deleted from BOTH hardcoded arrays in .github/workflows/shell-tests.yml; every name still listed there resolves to a file that exists.",
"check_and_enable_services.sh runs to completion and reports ok for pacman_wrapper, makepkg_wrapper, midnight_shutdown, startup_monitor, periodic_systems and hosts.",
"The pacman-wrapper drift manifest verifies with sha256sum -c after regeneration.",
"report_live_state.sh --check reports ZERO stale linux_configuration/scripts/ references (down from 8).",
"No lint suppression is added anywhere."
],
"out_of_scope": [
"Fixing the unwrapped discord/signal-desktop binaries -- pre-existing live state, identical at baseline (18 passed / 1 failed / 5 skipped both before and after).",
"The workout_locker error, which concerns the separate screen-locker repo.",
"The Nextcloud dedup, measured and recommended against in the handoff.",
"Reformatting vendor/common.sh, which is a verbatim copy."
],
"verifier": "./meta/scripts/check_directory_depth.sh --all exits 0; ~/digital-wellbeing/tests/run_all.sh passes all seven suites; grep for the six moved test names in shell-tests.yml returns 0; sudo check_and_enable_services.sh completes with no unbound-variable error and no missing-script error; sudo sha256sum -c --status /var/lib/pacman-wrapper/source.sha256 exits 0; report_live_state.sh --check greps 0 for linux_configuration/scripts/; shellcheck clean on all nine changed shell files."
}

View File

@ -0,0 +1,92 @@
{
"intent": "Extract the last over-cap unit into github.com/kuhyx/digital-wellbeing (cloned at ~/digital-wellbeing), then delete --warn from the directory-depth-cap hook so the cap enforces for real. This is the closing step of the restructure campaign.",
"scope": [
"REMOVED: linux_configuration/periodic_background/digital_wellbeing/ (80 tracked files)",
"REMOVED: 6 test suites + 2 test libs from linux_configuration/tests/",
"REWIRED: check_and_enable_services.sh, setup_periodic_system.sh, install_core_system.sh, fresh-install/main.sh, installers/install_ungoogled_chromium_ubuntu.sh, tests/test_security_hardening.sh, tests/lib/security_apps.sh, tests/lib/security_pacman.sh",
"CONFIG: .pre-commit-config.yaml (--warn removed, 2 stale excludes dropped), .github/workflows/shell-tests.yml, meta/shell-coverage-allowlist.txt, meta/pyproject.toml, .gitignore, meta/scripts/check_directory_depth.sh",
"LIVE: /usr/local/bin/periodic-system-maintenance.sh",
"NON-GOAL: the unwrapped discord/signal-desktop binaries (pre-existing) and the workout_locker error (different repo)"
],
"changes": [
"Filtered history across all three paths the unit has lived at (63 commits). The current path alone holds 2 -- the same trap as hosts-blocker, and the reason the count was checked before filtering rather than after.",
"The six suites that test this code lived in linux_configuration/tests/, not beside it. They were filtered separately (24 commits) and merged as a second root, then their hardcoded periodic_background/digital_wellbeing/ prefixes rewritten for the new root-level layout. A path filter of the unit alone would have left them in the monorepo testing code that had left it.",
"Vendored linux_configuration/lib/common.sh into vendor/ rather than trimming it: set_actual_user_vars calls get_actual_user and get_actual_user_home, so a 4-function trim breaks silently. It went to vendor/ and not lib/ because test_music_parallelism.sh copies the unit's lib/ wholesale into a scratch worktree while stubbing the shared lib -- with both in one directory the real common.sh overwrote the stub and the test measured the wrong library.",
"Generalised that test's layout-drift guard from a required ../ prefix to (../)*; the script now sits at the repo root so the prefix is empty. The guard caught this move, which is what it was written for.",
"Dropped the two .pre-commit-config.yaml excludes for setup_midnight_shutdown.sh instead of moving them: lsattr shows nothing in the unit is chattr +i any more, and the fixer hooks leave the file untouched when allowed to run.",
"Removed --warn from the directory-depth-cap hook and reworded its comment from 'deliberately --warn for now' to state that re-adding it re-opens the hole."
],
"verification": [
{
"command": "./meta/scripts/check_directory_depth.sh --all",
"result": "pass",
"evidence": "'all checked paths are within 2 directories', exit 0. 46 -> 41 (after hosts) -> 0. The cap now enforces without --warn."
},
{
"command": "per-root commit counts before filtering",
"result": "pass",
"evidence": "current path 2, scripts/periodic_background/ 41, scripts/digital_wellbeing/ 23; 63 on main. The 94 an --all union reported included unreachable refs and one merge commit that filter-repo correctly prunes."
},
{
"command": "cmp each of the 80 tracked files against the monorepo copy",
"result": "pass",
"evidence": "ALL 80 TRACKED FILES IDENTICAL. node_modules (340 files) and __pycache__ correctly excluded -- the repo has 80 tracked files, not the 423 on disk."
},
{
"command": "~/digital-wellbeing/tests/run_all.sh",
"result": "pass",
"evidence": "All six regression suites plus the discovered lib/tests/run_all.sh pass: 'All digital-wellbeing suites passed.'"
},
{
"command": "env -i PATH=/usr/bin:/bin HOME=/tmp bash ./tests/run_all.sh",
"result": "pass",
"evidence": "Passes under a minimal environment, and no suite needs root or invokes pacman -- so the new repo needs no arch-tests job even though five of these ran in the monorepo's Arch container."
},
{
"command": "sudo check_and_enable_services.sh (first attempt)",
"result": "fail",
"evidence": "'line 61: DW_REPO: unbound variable' -- the definition sat below its first use. shellcheck and grep both passed the file; only running it caught this."
},
{
"command": "sudo check_and_enable_services.sh (after moving the block)",
"result": "pass",
"evidence": "Runs to completion: pacman_wrapper ok, makepkg_wrapper ok, midnight_shutdown ok, startup_monitor ok, periodic_systems ok, hosts ok, guest_mode_removal ok. Applied 3 fix(es)."
},
{
"command": "sudo sha256sum -c --status /var/lib/pacman-wrapper/source.sha256",
"result": "pass",
"evidence": "Exit 0. The manifest embeds ABSOLUTE paths and had named deleted scripts/ paths since the flatten, so the drift check reported a permanent false 'stale or tampered'. It now names ~/digital-wellbeing and verifies -- with unchanged hashes, confirming the extracted bytes are identical."
},
{
"command": "report_live_state.sh --check | grep -c linux_configuration/scripts/",
"result": "pass",
"evidence": "0, down from the 8 pre-existing stale references at session start. Removals only; none added at any point."
},
{
"command": "grep the six moved test names in .github/workflows/shell-tests.yml",
"result": "pass",
"evidence": "0 matches, and all four names still listed (test_i3blocks_persist_common, test_setup_dns_blocker, test_i3blocks_efficiency, test_security_hardening) resolve to files that exist."
},
{
"command": "test_security_hardening.sh, current tree vs a detached worktree at HEAD",
"result": "pass",
"evidence": "18 passed / 1 failed / 5 skipped in BOTH. The one failure is 'Compulsive block wrappers installed' -- discord and signal-desktop lack their /usr/bin wrapper. Live-system state, identical before and after."
},
{
"command": "shellcheck on all nine changed shell files",
"result": "pass",
"evidence": "ALL CLEAN, no severity filter."
}
],
"risks": [
"vendor/common.sh duplicates 163 lines of the monorepo lib. The two copies may drift; that is accepted, and the README says so, so a future session does not 're-fix' it by re-coupling the repos.",
"The pacman wrapper's drift manifest is regenerated by the checker's own fix path. A machine that clones digital-wellbeing to a non-default location must re-run install_pacman_wrapper.sh or the manifest will name the wrong paths.",
"install_core_system.sh on a fresh machine now hard-stops at three modules until both extracted repos are cloned. Deliberate and loud; INSTALLER_FIX_TASK.md tracks fresh-machine installer defects.",
"The depth cap is now enforcing, so any future path more than two directories deep is a hard commit failure rather than a warning."
],
"rollback": [
"git revert this commit to restore the unit, the six suites, the two test libs and every rewired caller, and to put --warn back on the depth hook.",
"Restore /var/tmp/periodic-system-maintenance.sh.pre-dw to /usr/local/bin/.",
"After rollback validate: check_directory_depth.sh --all reports 41 violations again, and sudo check_and_enable_services.sh still completes without an unbound-variable error."
]
}

View File

@ -81,8 +81,16 @@ fi
# --threshold 2: fail if more than 2% duplication # --threshold 2: fail if more than 2% duplication
# Excluded from the scan, and why: # Excluded from the scan, and why:
# .venv/ vendored third-party shell (virtualenv, tqdm completions). # .venv/ vendored third-party shell (virtualenv, tqdm completions).
# .ci-mirror-venv/
# Not ours, and it alone pushed the repo over the bar: the # Not ours, and it alone pushed the repo over the bar: the
# same scan reads 2.50% with it and 1.67% without. # same scan reads 2.50% with it and 1.67% without.
# .ci-mirror-venv/ is the SAME third-party shell under a
# different name -- the clean-venv the ci-mirror pre-push gate
# builds. It is untracked and generated, but jscpd walks the
# working tree, not the index, so it was still being scanned.
# Extracting digital_wellbeing shrank the denominator enough
# for those virtualenv/tqdm clones to push the total to 2.09%
# and block a commit that added no duplication at all.
# lib/tests/ a fixture IS repetitive by design; the 2% rule exists to # lib/tests/ a fixture IS repetitive by design; the 2% rule exists to
# stop duplicated production logic, not shared test setup. # stop duplicated production logic, not shared test setup.
# Needed headroom for the Phase 3 coverage campaign. # Needed headroom for the Phase 3 coverage campaign.
@ -92,7 +100,7 @@ jscpd_output=$("$JSCPD_BIN" \
--min-tokens 25 \ --min-tokens 25 \
--threshold 2 \ --threshold 2 \
--reporters "console" \ --reporters "console" \
--ignore "**/node_modules/**,**/.git/**,**/misc/**,**/*.txt,**/.venv/**,**/lib/tests/**,**/tests/**" \ --ignore "**/node_modules/**,**/.git/**,**/misc/**,**/*.txt,**/.venv/**,**/.ci-mirror-venv/**,**/lib/tests/**,**/tests/**" \
. 2>&1) || jscpd_exit=$? . 2>&1) || jscpd_exit=$?
if [[ ${jscpd_exit:-0} -ne 0 ]]; then if [[ ${jscpd_exit:-0} -ne 0 ]]; then

View File

@ -179,7 +179,7 @@ fi
cd ~/linux-configuration cd ~/linux-configuration
sudo "$HOME/hosts-blocker/install.sh" sudo "$HOME/hosts-blocker/install.sh"
i3/install.sh i3/install.sh
periodic_background/digital_wellbeing/pacman/install_pacman_wrapper.sh "$HOME/digital-wellbeing/pacman/install_pacman_wrapper.sh"
fixes/nvidia_troubleshoot.sh fixes/nvidia_troubleshoot.sh
sudo features/setup_activitywatch.sh sudo features/setup_activitywatch.sh
sudo utils/setup_media_organizer.sh sudo utils/setup_media_organizer.sh

View File

@ -8,7 +8,7 @@
# #
# SECONDARY modules (prompted unless --all / --none given): # SECONDARY modules (prompted unless --all / --none given):
# 4. Steam backlog enforcer – python_pkg/steam_backlog_enforcer/ # 4. Steam backlog enforcer – python_pkg/steam_backlog_enforcer/
# 5. Pacman wrapper – periodic_background/digital_wellbeing/pacman/ # 5. Pacman wrapper – github.com/kuhyx/digital-wellbeing (~/digital-wellbeing)
# 6. i3 configuration – i3/ # 6. i3 configuration – i3/
# 7. Compulsive opening block – block_compulsive_opening.sh # 7. Compulsive opening block – block_compulsive_opening.sh
# #
@ -135,7 +135,7 @@ run_installer "Hosts blocking" \
bash "$(extracted_repo_dir hosts-blocker)/install.sh" bash "$(extracted_repo_dir hosts-blocker)/install.sh"
run_installer "Midnight shutdown timer" \ run_installer "Midnight shutdown timer" \
bash "$LINUX_CONFIG/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh" bash "$(extracted_repo_dir digital-wellbeing)/setup_midnight_shutdown.sh"
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# SECONDARY MODULES (prompted unless --all / --none) # SECONDARY MODULES (prompted unless --all / --none)
@ -146,13 +146,13 @@ ask_install "Steam backlog enforcer" \
sudo bash "$REPO_ROOT/python_pkg/steam_backlog_enforcer/install.sh" sudo bash "$REPO_ROOT/python_pkg/steam_backlog_enforcer/install.sh"
ask_install "Pacman wrapper" \ ask_install "Pacman wrapper" \
bash "$LINUX_CONFIG/periodic_background/digital_wellbeing/pacman/install_pacman_wrapper.sh" bash "$(extracted_repo_dir digital-wellbeing)/pacman/install_pacman_wrapper.sh"
ask_install "i3 configuration" \ ask_install "i3 configuration" \
bash "$LINUX_CONFIG/i3/install.sh" bash "$LINUX_CONFIG/i3/install.sh"
ask_install "Compulsive opening blockade" \ ask_install "Compulsive opening blockade" \
sudo bash "$LINUX_CONFIG/periodic_background/digital_wellbeing/block_compulsive_opening.sh" install sudo bash "$(extracted_repo_dir digital-wellbeing)/block_compulsive_opening.sh" install
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
print_summary print_summary

View File

@ -40,7 +40,9 @@ set -Eeuo pipefail
SCRIPT_NAME=${0##*/} SCRIPT_NAME=${0##*/}
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
DIGITAL_WELLBEING_DIR="$SCRIPT_DIR/../periodic_background/digital_wellbeing" # shellcheck source=../lib/extracted_repos.sh
source "$SCRIPT_DIR/../lib/extracted_repos.sh"
DIGITAL_WELLBEING_DIR="$(extracted_repo_dir digital-wellbeing)"
SEEDER_SCRIPT="$DIGITAL_WELLBEING_DIR/seed_ublock_storage.js" SEEDER_SCRIPT="$DIGITAL_WELLBEING_DIR/seed_ublock_storage.js"
UBLOCK_EXT_PATH="/usr/share/chromium/extensions/ublock-origin" UBLOCK_EXT_PATH="/usr/share/chromium/extensions/ublock-origin"

View File

@ -57,38 +57,41 @@ NC=$'\033[0m' # No Color
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
CONFIG_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" CONFIG_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
# Resolve the invoking user's home: this script re-execs itself via sudo, so
# $HOME would be /root here.
REAL_USER="${SUDO_USER:-${USER:-$(id -un)}}"
REAL_HOME="$(getent passwd "$REAL_USER" 2>/dev/null | cut -d: -f6)"
[[ -n $REAL_HOME ]] || REAL_HOME="/home/$REAL_USER"
HOSTS_BLOCKER_REPO="${HOSTS_BLOCKER_DIR:-$REAL_HOME/hosts-blocker}"
# digital-wellbeing was EXTRACTED too (github.com/kuhyx/digital-wellbeing).
DW_REPO="${DIGITAL_WELLBEING_DIR:-$REAL_HOME/digital-wellbeing}"
# Script paths # Script paths
PACMAN_WRAPPER_INSTALL="$CONFIG_DIR/periodic_background/digital_wellbeing/pacman/install_pacman_wrapper.sh" PACMAN_WRAPPER_INSTALL="$DW_REPO/pacman/install_pacman_wrapper.sh"
MAKEPKG_WRAPPER_INSTALL="$CONFIG_DIR/periodic_background/digital_wellbeing/pacman/install_makepkg_wrapper.sh" MAKEPKG_WRAPPER_INSTALL="$DW_REPO/pacman/install_makepkg_wrapper.sh"
# Drift manifests written by the two installers above (see deployment_drift). # Drift manifests written by the two installers above (see deployment_drift).
PACMAN_WRAPPER_MANIFEST="/var/lib/pacman-wrapper/source.sha256" PACMAN_WRAPPER_MANIFEST="/var/lib/pacman-wrapper/source.sha256"
MAKEPKG_WRAPPER_MANIFEST="/var/lib/pacman-wrapper/makepkg-source.sha256" MAKEPKG_WRAPPER_MANIFEST="/var/lib/pacman-wrapper/makepkg-source.sha256"
MIDNIGHT_SHUTDOWN_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh" MIDNIGHT_SHUTDOWN_SCRIPT="$DW_REPO/setup_midnight_shutdown.sh"
STARTUP_MONITOR_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/setup_pc_startup_monitor.sh" STARTUP_MONITOR_SCRIPT="$DW_REPO/setup_pc_startup_monitor.sh"
PERIODIC_SYSTEM_SCRIPT="$CONFIG_DIR/periodic_background/setup_periodic_system.sh" PERIODIC_SYSTEM_SCRIPT="$CONFIG_DIR/periodic_background/setup_periodic_system.sh"
# hosts-blocker was EXTRACTED (github.com/kuhyx/hosts-blocker). Resolved # hosts-blocker was EXTRACTED (github.com/kuhyx/hosts-blocker). Resolved
# below, after REAL_HOME is computed, for the same reason screen-locker is. # below, after REAL_HOME is computed, for the same reason screen-locker is.
HOSTS_INSTALL_SCRIPT="" HOSTS_INSTALL_SCRIPT=""
GUARD_LIB_MIGRATE_SCRIPT="$CONFIG_DIR/./fixes/migrate_hosts_guard_to_guard_lib.sh" GUARD_LIB_MIGRATE_SCRIPT="$CONFIG_DIR/./fixes/migrate_hosts_guard_to_guard_lib.sh"
COMPULSIVE_BLOCK_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/block_compulsive_opening.sh" COMPULSIVE_BLOCK_SCRIPT="$DW_REPO/block_compulsive_opening.sh"
LEECHBLOCK_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/install_leechblock.sh" LEECHBLOCK_SCRIPT="$DW_REPO/install_leechblock.sh"
REMOVE_GUEST_MODE_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/remove_guest_mode.sh" REMOVE_GUEST_MODE_SCRIPT="$DW_REPO/remove_guest_mode.sh"
VBOX_HOSTS_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh" VBOX_HOSTS_SCRIPT="$DW_REPO/virtualbox/enforce_vbox_hosts.sh"
# screen-locker was EXTRACTED out of this monorepo into its own repo # screen-locker was EXTRACTED out of this monorepo into its own repo
# (github.com/kuhyx/screen-locker, checked out at ~/screen-locker), so these # (github.com/kuhyx/screen-locker, checked out at ~/screen-locker), so these
# paths deliberately live outside testsAndMisc. They used to point at # paths deliberately live outside testsAndMisc. They used to point at
# python_pkg/screen_locker/, which stopped existing at extraction time — the # python_pkg/screen_locker/, which stopped existing at extraction time — the
# result was check_workout_locker reporting a red "error" for a service that was # result was check_workout_locker reporting a red "error" for a service that was
# installed and enabled the whole time, while its "fix" silently did nothing. # installed and enabled the whole time, while its "fix" silently did nothing.
# Resolve the invoking user's home: this script re-execs itself via sudo, so
# $HOME would be /root here.
REAL_USER="${SUDO_USER:-${USER:-$(id -un)}}"
REAL_HOME="$(getent passwd "$REAL_USER" 2>/dev/null | cut -d: -f6)"
[[ -n $REAL_HOME ]] || REAL_HOME="/home/$REAL_USER"
WORKOUT_LOCKER_REPO="$REAL_HOME/screen-locker" WORKOUT_LOCKER_REPO="$REAL_HOME/screen-locker"
WORKOUT_LOCKER_INSTALL_SCRIPT="$WORKOUT_LOCKER_REPO/install_systemd.sh" WORKOUT_LOCKER_INSTALL_SCRIPT="$WORKOUT_LOCKER_REPO/install_systemd.sh"
WORKOUT_LOCKER_SCRIPT="$WORKOUT_LOCKER_REPO/screen_locker/screen_lock.py" WORKOUT_LOCKER_SCRIPT="$WORKOUT_LOCKER_REPO/screen_locker/screen_lock.py"
HOSTS_BLOCKER_REPO="${HOSTS_BLOCKER_DIR:-$REAL_HOME/hosts-blocker}"
HOSTS_INSTALL_SCRIPT="$HOSTS_BLOCKER_REPO/install.sh" HOSTS_INSTALL_SCRIPT="$HOSTS_BLOCKER_REPO/install.sh"
# A MISSING repair script means THIS TOOL is broken: it silently stops repairing # A MISSING repair script means THIS TOOL is broken: it silently stops repairing

View File

@ -1 +0,0 @@
node_modules/

View File

@ -1,245 +0,0 @@
# Block Compulsive Opening - LLM Reference Guide
> **For AI assistants**: This document explains the compulsive opening blocker so you can make correct modifications.
## System Purpose
Limit messaging apps (Beeper, Signal, Discord) to **one launch per hour** to reduce compulsive checking behavior.
## How It Works
```
┌─────────────────────────────────────────────────────────────────────┐
│ LAUNCH INTERCEPTION │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ User clicks "Discord" in app launcher │
│ ↓ │
│ /usr/bin/discord (wrapper script) │
│ ↓ │
│ exec /usr/local/bin/block-compulsive-opening.sh wrapper discord │
│ ↓ │
│ Check: ~/.local/state/compulsive-block/discord.lastopen │
│ ↓ │
│ ┌─────────────────┴─────────────────┐ │
│ │ │ │
│ ▼ Not opened this hour ▼ Already opened │
│ Record opening time Show notification │
│ Launch real binary Exit with error │
│ /opt/discord/Discord │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
## File Locations
| File | Purpose |
| ------------------------------------------------------ | --------------------------- |
| `/usr/local/bin/block-compulsive-opening.sh` | Installed main script |
| `/usr/bin/beeper` | Wrapper (replaces original) |
| `/usr/bin/signal-desktop` | Wrapper (replaces original) |
| `/usr/bin/discord` | Wrapper (replaces original) |
| `/usr/bin/*.orig` or `SYMLINK:*` | Original binaries/links |
| `~/.local/state/compulsive-block/*.lastopen` | Per-app hour tracking |
| `~/.local/state/compulsive-block/compulsive-block.log` | Activity log |
| `/etc/pacman.d/hooks/95-compulsive-block-rewrap.hook` | Auto-rewrap hook |
## Managed Applications
```bash
declare -A APPS=(
["beeper"]="/usr/bin/beeper"
["signal-desktop"]="/usr/bin/signal-desktop"
["discord"]="/usr/bin/discord"
)
declare -A REAL_BINARIES=(
["beeper"]="/opt/beeper/beepertexts"
["signal-desktop"]="/usr/lib/signal-desktop/signal-desktop"
["discord"]="/opt/discord/Discord"
)
```
## State Tracking
Hour key format: `YYYY-MM-DD-HH` (e.g., `2026-02-02-14`)
State file content: Just the hour key string
```bash
# Check if opened this hour
cat ~/.local/state/compulsive-block/discord.lastopen
# Output: 2026-02-02-14
# Current hour
date '+%Y-%m-%d-%H'
# Output: 2026-02-02-15 (different = can open again)
```
## Wrapper Installation Process
When `install_all()` runs:
1. Copies script to `/usr/local/bin/block-compulsive-opening.sh`
2. For each app:
- If original is a symlink: Save `SYMLINK:/target/path` to `.orig`
- If original is a file: Move to `.orig`
- Create wrapper script at original location:
```bash
#!/bin/bash
exec /usr/local/bin/block-compulsive-opening.sh wrapper "discord" "$@"
```
3. Install pacman hook for auto-rewrap
## Pacman Hook
After beeper/signal/discord package updates, the hook re-wraps them:
```ini
[Trigger]
Operation = Upgrade
Operation = Install
Type = Package
Target = beeper
Target = signal-desktop
Target = discord
[Action]
When = PostTransaction
Exec = /usr/local/bin/block-compulsive-opening.sh rewrap-quiet
```
The `rewrap-quiet` command:
- Checks if wrapper was overwritten (doesn't contain "block-compulsive-opening")
- If overwritten: removes stale `.orig`, re-installs wrapper
- Logs to activity log
## Commands
```bash
# Install all wrappers (requires root)
sudo ./block_compulsive_opening.sh install
# Uninstall all wrappers (requires root)
sudo ./block_compulsive_opening.sh uninstall
# Check status of all apps
./block_compulsive_opening.sh status
# Reset a specific app (allow opening again this hour)
./block_compulsive_opening.sh reset discord
# Reset all apps
./block_compulsive_opening.sh reset-all
```
## Log Format
```
2026-02-02 14:30:15 - ALLOWED: discord opened (first time this hour: 2026-02-02-14)
2026-02-02 14:30:15 - LAUNCHED: discord with PID 12345 (auto-close in 10m)
2026-02-02 14:38:15 - (notification: "Session will end in 2 minutes")
2026-02-02 14:40:15 - AUTO-CLOSED: discord (PID 12345) after 10m
2026-02-02 14:45:22 - BLOCKED: discord launch prevented (already opened this hour: 2026-02-02-14)
2026-02-02 15:01:03 - ALLOWED: discord opened (first time this hour: 2026-02-02-15)
2026-02-02 15:30:00 - RESET: discord state cleared by user
```
## Auto-Close Timer (Session Limit)
Apps are automatically closed after **10 minutes** to prevent indefinite usage:
1. When app launches, a background daemon is spawned
2. At **8 minutes**: Warning notification "Session will end in 2 minutes"
3. At **10 minutes**: App is closed with SIGTERM, then SIGKILL if needed
4. State file `~/.local/state/compulsive-block/<app>.running` tracks PID and start time
**Configuration variables** (in script):
```bash
AUTO_CLOSE_TIMEOUT_MINUTES=10 # Total session length
AUTO_CLOSE_WARNING_MINUTES=2 # Warning before close
```
## Adding a New App
1. Add to `APPS` associative array:
```bash
declare -A APPS=(
# ... existing apps ...
["newapp"]="/usr/bin/newapp"
)
```
2. Add to `REAL_BINARIES`:
```bash
declare -A REAL_BINARIES=(
# ... existing apps ...
["newapp"]="/opt/newapp/actual-binary"
)
```
3. Add to pacman hook targets (if installed via pacman):
```ini
Target = newapp
```
4. Reinstall:
```bash
sudo ./block_compulsive_opening.sh install
```
## Debugging
### Check if wrapper is installed
```bash
cat /usr/bin/discord
# Should show wrapper script, not binary
ls -la /usr/bin/discord.orig
# Should exist (or check for SYMLINK: content)
```
### Check current state
```bash
./block_compulsive_opening.sh status
# Shows: which apps are wrapped, last open times, current hour
```
### Test manually
```bash
# Simulate wrapper call
/usr/local/bin/block-compulsive-opening.sh wrapper discord
```
### View logs
```bash
tail -f ~/.local/state/compulsive-block/compulsive-block.log
```
## Notification Behavior
When blocked, shows desktop notification:
- Title: "🚫 discord Blocked"
- Message: "Already opened this hour. Wait until the next hour."
- Urgency: critical
- Timeout: 5000ms
Uses `notify-send` (falls back silently if not available).
## DO NOT
1. ❌ Delete `.orig` files (cannot restore original binaries)
2. ❌ Manually edit wrapper scripts at `/usr/bin/` (will be overwritten)
3. ❌ Assume app is "blocked" once notification shows (it ran, just not again)
4. ❌ Remove pacman hook without understanding auto-rewrap won't work

View File

@ -1,162 +0,0 @@
# Midnight Shutdown System - LLM Reference Guide
> **For AI assistants**: This document explains the automatic shutdown system so you can make correct modifications.
## System Purpose
Automatically shut down the PC during configured time windows to enforce healthy sleep schedules:
- **Monday-Wednesday**: Shutdown at 24:00 (midnight)
- **Thursday-Sunday**: Shutdown at 24:00 (midnight)
- **Morning**: Safe time starts at 00:00 (effectively no morning block)
The times above are defaults; actual values in `/etc/shutdown-schedule.conf`.
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────────┐
│ SHUTDOWN SYSTEM LAYERS │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Layer 1: Systemd Timer │
│ ───────────────────── │
│ day-specific-shutdown.timer fires every minute │
│ day-specific-shutdown.service runs the check script │
│ │
│ Layer 2: Check Script │
│ ──────────────────── │
│ /usr/local/bin/day-specific-shutdown-check.sh │
│ Reads config, checks current time, initiates shutdown if in window │
│ │
│ Layer 3: Config Protection │
│ ──────────────────────── │
│ /etc/shutdown-schedule.conf has chattr +i │
│ Canonical copy at /usr/local/share/locked-shutdown-schedule.conf │
│ Path watcher auto-restores if tampered │
│ │
│ Layer 4: Timer Monitor │
│ ───────────────────── │
│ shutdown-timer-monitor.service watches timer status │
│ Re-enables timer if user tries to disable it │
│ │
│ Layer 5: Script Protection │
│ ──────────────────────── │
│ Setup script blocks making schedule MORE LENIENT │
│ Can only make it STRICTER without the unlock script │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
## File Locations
| File | Purpose | Protection |
| ----------------------------------------------------- | ------------------- | ----------------------- |
| `/etc/shutdown-schedule.conf` | Runtime config | chattr +i, path watcher |
| `/usr/local/share/locked-shutdown-schedule.conf` | Canonical copy | chattr +i |
| `/usr/local/bin/day-specific-shutdown-check.sh` | Shutdown logic | None |
| `/usr/local/bin/day-specific-shutdown-manager.sh` | Status/management | None |
| `/usr/local/bin/shutdown-timer-monitor.sh` | Timer re-enabler | None |
| `/usr/local/sbin/enforce-shutdown-schedule.sh` | Config restoration | None |
| `/usr/local/sbin/unlock-shutdown-schedule` | Delayed config edit | None |
| `/etc/systemd/system/day-specific-shutdown.timer` | Timer unit | systemd |
| `/etc/systemd/system/day-specific-shutdown.service` | Service unit | systemd |
| `/etc/systemd/system/shutdown-schedule-guard.path` | Config watcher | systemd |
| `/etc/systemd/system/shutdown-schedule-guard.service` | Enforcement | systemd |
| `/etc/systemd/system/shutdown-timer-monitor.service` | Timer guardian | systemd |
| `/var/log/shutdown-schedule-guard.log` | Tampering log | None |
## Config File Format
```bash
# /etc/shutdown-schedule.conf
# Shutdown hour for Monday-Wednesday (24-hour format)
MON_WED_HOUR=21
# Shutdown hour for Thursday-Sunday (24-hour format)
THU_SUN_HOUR=22
# Morning end hour (shutdown window ends at this hour)
MORNING_END_HOUR=5
```
**Interpretation**:
- Mon-Wed: Shutdown if current hour >= 21 OR current hour < 5
- Thu-Sun: Shutdown if current hour >= 22 OR current hour < 5
## Schedule Protection Logic
The setup script (`setup_midnight_shutdown.sh`) has constants at the top:
```bash
SCHEDULE_MON_WED_HOUR=24
SCHEDULE_THU_SUN_HOUR=24
SCHEDULE_MORNING_END_HOUR=0
```
When re-run, it compares these to the canonical config:
| Change Type | Action |
| -------------------------- | ------------------------------------ |
| Making shutdown EARLIER | ✅ Allowed without unlock |
| Making shutdown LATER | ❌ Blocked, requires unlock |
| Making morning end EARLIER | ❌ Always blocked |
| Making morning end LATER | ✅ Allowed (extends shutdown window) |
Example blocked attempt:
```
╔══════════════════════════════════════════════════════════════════╗
║ ❌ SCHEDULE MODIFICATION BLOCKED - CHEATING DETECTED! ❌ ║
╚══════════════════════════════════════════════════════════════════╝
You modified the script to make the shutdown schedule MORE LENIENT:
• Mon-Wed shutdown: 21:00 → 23:00 (later)
Nice try! But this is exactly the kind of late-night bargaining
that this protection is designed to prevent. 😉
```
## Unlock Script Behavior
`/usr/local/sbin/unlock-shutdown-schedule`:
1. Stops `shutdown-schedule-guard.path`
2. Removes chattr from both config files
3. Opens editor on temp copy
4. Checks what changed:
- **Stricter (earlier)**: No delay, applies immediately
- **Lenient (later)**: 45-second countdown, then applies
- **Lower morning end**: **ALWAYS BLOCKED** (cannot shorten window)
5. Updates both config and canonical
6. Re-applies chattr +i
7. Restarts path watcher
## Integration Points
### i3blocks Countdown
`i3blocks/shutdown_countdown.sh` reads the config to show time remaining:
```bash
source /etc/shutdown-schedule.conf
# Calculates and displays "Shutdown in X:XX"
```
### Screen Locker
`screen_lock.py` can adjust shutdown time:
- **Sick day**: Moves shutdown 1.5 hours EARLIER (penalty)
- **Workout completed**: Moves shutdown 1.5 hours LATER (reward)
Uses `adjust_shutdown_schedule.sh` helper script.
## More detail
Split out to stay under the 250-line cap.
- [Systemd units, check-script logic and common tasks](llm-notes/units-and-tasks.md)
- [Known vulnerabilities, troubleshooting and hard stops](llm-notes/vulns-and-troubleshooting.md)

View File

@ -1,182 +0,0 @@
#!/bin/bash
# Block Compulsive Opening Script
# Limits messaging apps (Beeper, Signal, Discord) to one launch per hour
#
# Each app can only be opened once per hour. If already opened this hour,
# subsequent launch attempts are blocked with a notification.
#
# Installation moves real binaries to *.real and symlinks to wrapper scripts.
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() {
local title="$1"
local message="$2"
local urgency="${3:-normal}"
local timeout="${4:-5000}"
if command -v notify-send &>/dev/null; then
notify-send -u "$urgency" -t "$timeout" "$title" "$message" 2>/dev/null || true
fi
}
# Configuration
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/compulsive-block"
LOG_FILE="$STATE_DIR/compulsive-block.log"
# Auto-close timeout in minutes (apps forcefully closed after this)
AUTO_CLOSE_TIMEOUT_MINUTES=10
# Warning before auto-close (in minutes before timeout)
AUTO_CLOSE_WARNING_MINUTES=2
# Per-app timeout overrides (apps not listed use AUTO_CLOSE_TIMEOUT_MINUTES)
declare -A APP_TIMEOUT_MINUTES=(
["beeper"]=20
["signal-desktop"]=20
)
# Apps to limit (name -> binary path)
# These are the primary wrapper locations (what the user calls)
declare -A APPS=(
["beeper"]="/usr/bin/beeper"
["signal-desktop"]="/usr/bin/signal-desktop"
["discord"]="/usr/bin/discord"
)
# The wrapper runs as the user ($SUDO_USER unset); the installer runs as root
# under sudo ($SUDO_USER set) — resolve the same home in both cases.
_cco_user="${SUDO_USER:-${USER:-$(id -un)}}"
_cco_home="$(getent passwd "$_cco_user" 2>/dev/null | cut -d: -f6)"
[[ -n $_cco_home ]] || _cco_home="/home/$_cco_user"
# Actual executable paths (the real binaries to exec after wrapper check)
# These are where the real code lives
#
# discord: the `discord` package ships ONLY /usr/bin/discord — a bootstrap shell
# script that downloads the app into ~/.config/discord/ and execs
# ~/.config/discord/Discord (a symlink to the current app-<version>/Discord that
# Discord's own updater maintains). /opt/discord/Discord belongs to a DIFFERENT
# discord package and does not exist here, so the old value made install_wrapper's
# `[[ -x $real_binary ]]` gate fail forever ("discord real binary not found") and
# discord was never limited. We exec the preserved LAUNCHER (.orig) rather than
# the app directly, so Discord's bootstrap/self-update still runs — pointing
# straight at ~/.config/discord/Discord would break launching whenever the
# updater moved that symlink.
declare -A REAL_BINARIES=(
["beeper"]="/opt/beeper/beepertexts"
["signal-desktop"]="/usr/lib/signal-desktop/signal-desktop"
["discord"]="/usr/bin/discord.orig"
)
# Pattern for detecting a RUNNING instance (pgrep -f). Defaults to the exec
# target, which is right when that target is the app itself. It is wrong when the
# exec target is a launcher that exec()s away: /usr/bin/discord.orig replaces
# itself with ~/.config/discord/<app>/Discord, so nothing would ever match
# ".orig" and the running-state tracking would look stale immediately.
declare -A PROCESS_MATCH=(
["discord"]="$_cco_home/.config/discord/"
)
# Main entry point
main() {
case "${1:-help}" in
install)
if [[ $EUID -ne 0 ]]; then
echo "Error: install requires root privileges"
echo "Run: sudo $0 install"
exit 1
fi
install_all
;;
uninstall)
if [[ $EUID -ne 0 ]]; then
echo "Error: uninstall requires root privileges"
echo "Run: sudo $0 uninstall"
exit 1
fi
uninstall_all
;;
status)
show_status
;;
reset)
if [[ -z ${2:-} ]]; then
echo "Error: specify app to reset"
echo "Apps: ${!APPS[*]}"
exit 1
fi
reset_app "$2"
;;
reset-all)
reset_all
;;
rewrap-quiet)
# Called by pacman hook - quietly re-wrap apps after package updates
if [[ $EUID -ne 0 ]]; then
exit 1
fi
rewrap_quiet
;;
wrapper)
if [[ -z ${2:-} ]]; then
echo "Error: wrapper requires app name"
exit 1
fi
wrapper_main "${@:2}"
;;
help | -h | --help)
show_usage
;;
*)
echo "Unknown command: $1"
show_usage
exit 1
;;
esac
}
main "$@"

View File

@ -1,232 +0,0 @@
#!/bin/bash
# ============================================================================
# heavy_job_lock.sh - one heavy job at a time on this machine
#
# This box has 7.6 GB. On 2026-08-03 a Gradle/Flutter APK build, a full
# `pytest --cov` run and Chromium overlapped; an earlier overlap the same week
# got a whole session OOM-killed (exit 137/143). The rule "one heavy job at a
# time" was written down in ~/.claude/memories/mistakes.md - but a rule that
# only works when a human remembers it is deployment hygiene, not a fix. This
# makes the overlap impossible instead.
#
# Usage as a CLI:
# heavy_job_lock.sh --name gradle -- flutter build apk --debug
# heavy_job_lock.sh --name pytest --fail-fast -- pytest --cov
#
# Usage as a library:
# source /path/to/heavy_job_lock.sh
# with_heavy_lock gradle -- ./gradlew assembleDebug
#
# Setup (once, as root - /run is tmpfs so the lock needs recreating on boot):
# sudo heavy_job_lock.sh --install
#
# Design notes:
# - ONE system-wide lock shared between root and the user. pacman runs as
# root while builds run as the user, so a per-user /run/user/$UID lock
# would give them separate locks and no mutual exclusion at all.
# - The lock file is mode 0666 (created by root via tmpfiles.d) precisely so
# an unprivileged build can take the same lock a root pacman takes.
# - FAIL OPEN, always. A lock this central must never be able to brick
# package management or builds: if the lock file cannot be created, or the
# wait times out, we warn and run the command anyway. Blocking work forever
# is a worse failure than the memory contention we are avoiding.
# ============================================================================
HEAVY_JOB_LOCK_FILE="${HEAVY_JOB_LOCK_FILE:-/run/heavy-job.lock}"
# Long enough for a real Gradle build to finish ahead of us, short enough that
# a wedged holder cannot stall a machine indefinitely.
HEAVY_JOB_LOCK_TIMEOUT="${HEAVY_JOB_LOCK_TIMEOUT:-1800}"
HEAVY_JOB_TMPFILES_CONF="/etc/tmpfiles.d/heavy-job-lock.conf"
_hjl_warn() { printf '[heavy-job-lock] %s\n' "$*" >&2; }
# Who holds it right now? Best-effort: the holder writes "<name> <pid> <date>"
# after acquiring, so a refusal can name the culprit instead of just failing.
_hjl_holder() {
local holder=""
[[ -r $HEAVY_JOB_LOCK_FILE ]] && read -r holder <"$HEAVY_JOB_LOCK_FILE" 2>/dev/null
printf '%s' "${holder:-unknown}"
}
# Create the lock file world-writable. Only meaningful as root; unprivileged
# callers just discover they cannot and fail open.
_hjl_ensure_lock_file() {
[[ -e $HEAVY_JOB_LOCK_FILE ]] && return 0
if [[ $EUID -eq 0 ]]; then
install -m 0666 /dev/null "$HEAVY_JOB_LOCK_FILE" 2>/dev/null && return 0
else
# Braces around the redirect: a failing redirection is reported by the
# shell itself, before the command runs, so `cmd 2>/dev/null` does not
# suppress it. The group does.
{ : >"$HEAVY_JOB_LOCK_FILE"; } 2>/dev/null &&
chmod 0666 "$HEAVY_JOB_LOCK_FILE" 2>/dev/null && return 0
fi
return 1
}
# with_heavy_lock <name> [--fail-fast] -- <command...>
with_heavy_lock() {
local name="$1"
shift
local fail_fast=0
while [[ $# -gt 0 ]]; do
case "$1" in
--fail-fast)
fail_fast=1
shift
;;
--)
shift
break
;;
*) break ;;
esac
done
if [[ $# -eq 0 ]]; then
_hjl_warn "with_heavy_lock: no command given"
return 2
fi
if ! _hjl_ensure_lock_file; then
_hjl_warn "cannot create $HEAVY_JOB_LOCK_FILE - running '$name' UNSERIALISED"
_hjl_warn "run 'sudo $0 --install' once to enable serialisation"
"$@"
return $?
fi
local lock_fd
# `<>` (read-write), NOT `>`. Redirecting with `>` truncates the file at
# open time, so a second caller wiped the holder's "<name> <pid> <date>"
# line before it could read it — every refusal said "held by unknown".
exec {lock_fd}<>"$HEAVY_JOB_LOCK_FILE" || {
_hjl_warn "cannot open lock - running '$name' unserialised"
"$@"
return $?
}
local acquired=0
if ((fail_fast == 1)); then
if flock --nonblock "$lock_fd"; then
acquired=1
else
_hjl_warn "refusing to start '$name': held by $(_hjl_holder)"
exec {lock_fd}>&-
return 75 # EX_TEMPFAIL
fi
else
local holder
holder="$(_hjl_holder)"
if ! flock --nonblock "$lock_fd"; then
_hjl_warn "waiting for '$holder' to finish before starting '$name' (timeout ${HEAVY_JOB_LOCK_TIMEOUT}s)"
fi
if flock --timeout "$HEAVY_JOB_LOCK_TIMEOUT" "$lock_fd"; then
acquired=1
else
# Fail open: see the header. A stuck holder must not permanently
# block builds or package management.
_hjl_warn "timed out after ${HEAVY_JOB_LOCK_TIMEOUT}s waiting for $(_hjl_holder)"
_hjl_warn "proceeding UNSERIALISED with '$name'"
fi
fi
if ((acquired == 1)); then
printf '%s %s %s\n' "$name" "$$" "$(date -Is 2>/dev/null || echo unknown)" \
>"$HEAVY_JOB_LOCK_FILE" 2>/dev/null || true
fi
local rc=0
"$@" || rc=$?
# Closing the fd releases the lock; do it explicitly so the holder line is
# not left implying we still hold it.
exec {lock_fd}>&-
return "$rc"
}
# ----------------------------------------------------------------------------
# CLI (skipped when this file is sourced as a library)
# ----------------------------------------------------------------------------
_hjl_install() {
if [[ $EUID -ne 0 ]]; then
_hjl_warn "--install must run as root"
return 1
fi
# /run is tmpfs: without a tmpfiles.d entry the lock vanishes every boot and
# the first unprivileged caller silently falls back to unserialised.
printf 'f %s 0666 root root -\n' "$HEAVY_JOB_LOCK_FILE" >"$HEAVY_JOB_TMPFILES_CONF"
systemd-tmpfiles --create "$HEAVY_JOB_TMPFILES_CONF" 2>/dev/null || true
_hjl_ensure_lock_file || {
_hjl_warn "failed to create $HEAVY_JOB_LOCK_FILE"
return 1
}
printf 'installed %s and %s\n' "$HEAVY_JOB_TMPFILES_CONF" "$HEAVY_JOB_LOCK_FILE"
}
_hjl_usage() {
cat <<EOF
Usage: $(basename "$0") --name <label> [--fail-fast] -- <command...>
$(basename "$0") --install # once, as root
$(basename "$0") --status
Serialises heavy jobs (builds, coverage runs, pacman transactions) so only one
runs at a time on this memory-constrained machine. Always fails open.
Options:
--name <label> Name recorded as the lock holder (shown to whoever waits)
--fail-fast Refuse immediately if held, instead of waiting
--install Create the lock file + tmpfiles.d entry (root)
--status Show the current holder
EOF
}
# ${BASH_SOURCE[0]} != $0 means we were sourced - define functions, run nothing.
if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then
set -uo pipefail
_name="heavy-job"
_fail_fast=()
while [[ $# -gt 0 ]]; do
case "$1" in
--name)
_name="$2"
shift 2
;;
--fail-fast)
_fail_fast=(--fail-fast)
shift
;;
--install)
_hjl_install
exit $?
;;
--status)
printf 'lock file: %s\n' "$HEAVY_JOB_LOCK_FILE"
printf 'holder: %s\n' "$(_hjl_holder)"
exit 0
;;
-h | --help)
_hjl_usage
exit 0
;;
--)
shift
break
;;
*)
_hjl_warn "unknown option: $1"
exit 2
;;
esac
done
if [[ $# -eq 0 ]]; then
_hjl_usage
exit 2
fi
with_heavy_lock "$_name" "${_fail_fast[@]}" -- "$@"
exit $?
fi

View File

@ -1,119 +0,0 @@
#!/usr/bin/env bash
# LeechBlockNG installer for Arch Linux (and derivatives)
# - Downloads the latest release from GitHub
# - Extracts it under ~/.local/share/leechblockng/<version>
# - Wires Chromium-based browsers to auto-load the extension via --load-extension
# - For Firefox-based browsers, prints safe next steps (stable Firefox requires signed XPI)
set -Eeuo pipefail
# Each phase of the install lives in a lib beside this file; this script
# keeps the flag parsing, the shared configuration and the order the phases
# run in.
# Resolved here, in the entry script, so it points at the directory holding
# leechblock_defaults.json and seed_leechblock_storage.js. Resolving it
# inside lib/ would yield lib/ itself and silently skip default seeding.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="$SCRIPT_DIR/lib"
# shellcheck source=lib/leechblock_fetch.sh
. "$LIB_DIR/leechblock_fetch.sh"
# shellcheck source=lib/leechblock_config.sh
. "$LIB_DIR/leechblock_config.sh"
# shellcheck source=lib/leechblock_browsers.sh
. "$LIB_DIR/leechblock_browsers.sh"
# shellcheck source=lib/leechblock_firefox.sh
. "$LIB_DIR/leechblock_firefox.sh"
SCRIPT_NAME=${0##*/}
info() { printf "\033[1;34m[INFO]\033[0m %s\n" "$*"; }
warn() { printf "\033[1;33m[WARN]\033[0m %s\n" "$*"; }
err() { printf "\033[1;31m[ERR ]\033[0m %s\n" "$*"; }
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
err "Missing dependency: $1"
MISSING=1
fi
}
usage() {
cat <<EOF
${SCRIPT_NAME} — Download and wire up LeechBlockNG from GitHub
Usage: ${SCRIPT_NAME} [--version vX.Y[.Z]] [--force] [--install-firefox]
Options:
--version vX.Y Use a specific tag (default: latest from GitHub)
--force Reinstall even if the same version is already present
--install-firefox Auto-install from AMO for detected Firefox-based browsers (requires sudo)
Notes:
- Chromium-based browsers are integrated via a wrapper that passes --load-extension.
A desktop entry "(LeechBlock)" is created so you can launch the browser with the extension.
- Firefox stable requires signed add-ons; GitHub source cannot be permanently installed there.
We'll print safe steps to install from AMO or use Developer Edition for testing.
EOF
}
VERSION=""
FORCE=0
AUTO_FIREFOX=0
while [[ $# -gt 0 ]]; do
case "$1" in
--version)
VERSION="$2"
shift 2
;;
--force)
FORCE=1
shift
;;
--install-firefox)
AUTO_FIREFOX=1
shift
;;
-h | --help)
usage
exit 0
;;
*)
err "Unrecognized option: $1"
usage
exit 2
;;
esac
done
# Dependencies
MISSING=0
require_cmd curl
require_cmd tar
require_cmd find
require_cmd sed
require_cmd awk
require_cmd unzip
if ! command -v jq >/dev/null 2>&1; then
warn "jq not found — will fall back to a simpler tag detection method."
fi
[[ $MISSING -eq 1 ]] && {
err "Please install missing tools and re-run."
exit 1
}
REPO_OWNER="proginosko"
REPO_NAME_CHROME="LeechBlockNG-chrome"
# Firefox repo (for reference): LeechBlockNG
# Use Chrome repo for Chromium-based browsers (the default target)
REPO_NAME="$REPO_NAME_CHROME"
resolve_version
download_extension
EXT_PATH="$CURRENT_LINK" # stable path used by wrappers
inject_default_config
detect_browsers
wire_up_browsers
install_firefox_policy "$ff_found"

View File

@ -1,225 +0,0 @@
#!/usr/bin/env bash
# uBlock Origin installer/wirer for Arch Linux (and derivatives)
# - Uses the ublock-origin pacman package already unpacked at /usr/lib/ublock-origin
# (install via `sudo pacman -S ublock-origin` if missing)
# - Wires Chromium-based browsers to auto-load it via --load-extension, alongside
# any extension a previous installer (e.g. install_leechblock.sh) already wired in
# - Seeds every selectable filter list into the extension's storage so uBO runs in
# full/classic mode with everything enabled, not just the shipped defaults
#
# IMPORTANT: --load-extension is last-wins across *separate* occurrences of the
# flag — Chromium silently drops every extension but the last one specified this
# way. Only a single, comma-joined "--load-extension=pathA,pathB" loads both.
# This script therefore never appends a second --load-extension flag: it always
# rewrites the existing flag (if any) into a comma-joined one.
set -Eeuo pipefail
SCRIPT_NAME=${0##*/}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
info() { printf "\033[1;34m[INFO]\033[0m %s\n" "$*"; }
warn() { printf "\033[1;33m[WARN]\033[0m %s\n" "$*"; }
err() { printf "\033[1;31m[ERR ]\033[0m %s\n" "$*"; }
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
err "Missing dependency: $1"
MISSING=1
fi
}
usage() {
cat <<EOF
${SCRIPT_NAME} — Wire up uBlock Origin (pacman package) for Chromium-based browsers
Usage: ${SCRIPT_NAME}
Notes:
- Requires the 'ublock-origin' pacman package (sudo pacman -S ublock-origin).
- Chromium-based browsers are integrated via the same wrapper pattern used by
install_leechblock.sh; running both installers preserves both extensions in
a single comma-joined --load-extension flag.
- Firefox-based browsers are out of scope here — install uBlock Origin from
addons.mozilla.org instead, permanent installs work there unlike LeechBlock.
EOF
}
case "${1:-}" in
-h | --help)
usage
exit 0
;;
"") ;;
*)
err "Unrecognized option: $1"
usage
exit 2
;;
esac
MISSING=0
require_cmd sed
require_cmd find
[[ $MISSING -eq 1 ]] && {
err "Please install missing tools and re-run."
exit 1
}
EXT_PATH="/usr/lib/ublock-origin"
if [[ ! -f "$EXT_PATH/manifest.json" ]]; then
err "uBlock Origin not found at $EXT_PATH (manifest.json missing)."
err "Install it first: sudo pacman -S ublock-origin"
exit 1
fi
# ── Seed default filter-list selection (all lists enabled) ────────────
if [[ ! -d "$SCRIPT_DIR/node_modules/classic-level" ]]; then
info "Installing classic-level npm package into $SCRIPT_DIR ..."
npm install --prefix "$SCRIPT_DIR" 2>&1 | grep -v '^npm warn' || true
fi
# Chrome locks its LevelDB files while running — close all Chromium browsers
# so the write succeeds.
pkill -f 'google-chrome|chromium|brave-browser|vivaldi|thorium' 2>/dev/null || true
sleep 1
if node "$SCRIPT_DIR/seed_ublock_storage.js"; then
info "Seeded uBlock Origin filter-list selection into browser storage"
else
warn "Could not seed uBlock Origin defaults — run manually after install:"
warn " node $SCRIPT_DIR/seed_ublock_storage.js"
fi
# ── Detect browsers ─────────────────────────────────────────────────────
declare -A BROWSERS
BROWSERS=(
[chromium]="Chromium"
[google-chrome-stable]="Google Chrome"
[google-chrome]="Google Chrome"
[brave-browser]="Brave"
[vivaldi-stable]="Vivaldi"
[vivaldi]="Vivaldi"
[opera]="Opera"
[thorium-browser]="Thorium"
)
found_any=0
# Add uBO to a browser launcher's --load-extension flag without dropping any
# extension a previous installer (LeechBlock) already wired in. Handles the
# same two cases as install_leechblock.sh's replace_browser_in_place:
# 1) The binary is a shell script with an "exec" line — patch it in-place.
# 2) The binary is a compiled ELF — wrap it with a shell script.
# Follows symlinks only one level, same as install_leechblock.sh, so shared
# wrapper scripts (e.g. browser-preexec-wrapper used by multiple symlinks)
# only get patched once regardless of which browser name triggered it.
wire_browser_in_place() {
local bin="$1"
shift
local pretty="$1"
shift
local bin_path
bin_path=$(command -v "$bin" || true)
[[ -z $bin_path ]] && return
local real_bin
real_bin=$(readlink -f "$bin_path")
# Already comma-joined in: nothing to do.
if grep -qE -- "--load-extension=\"[^\"]*${EXT_PATH//\//\\/}[^\"]*\"" "$real_bin" 2>/dev/null; then
info "$pretty ($bin) already has uBlock Origin in --load-extension — skipping"
found_any=1
return
fi
# Case 1: Shell script with an exec line carrying an existing
# --load-extension="..." flag (most likely from install_leechblock.sh) —
# rewrite it in-place into a comma-joined flag so both extensions load.
if file "$real_bin" 2>/dev/null | grep -qi 'text\|script'; then
if grep -qE -- '--load-extension="[^"]*"' "$real_bin" 2>/dev/null; then
info "Adding uBlock Origin to existing --load-extension flag in $real_bin…"
pkill -f "$real_bin" 2>/dev/null || true
sleep 1
local orig_backup="${real_bin}.orig"
if [[ ! -f $orig_backup ]]; then
info "Backing up $real_bin → $orig_backup"
sudo cp -a "$real_bin" "$orig_backup"
fi
sudo sed -i -E "s|--load-extension=\"([^\"]*)\"|--load-extension=\"\1,${EXT_PATH}\"|" "$real_bin"
info "✓ $pretty --load-extension flag now includes uBlock Origin"
found_any=1
return
fi
if grep -qE '^exec ' "$real_bin"; then
info "Patching exec line in $real_bin to add uBlock Origin…"
pkill -f "$real_bin" 2>/dev/null || true
sleep 1
local orig_backup="${real_bin}.orig"
if [[ ! -f $orig_backup ]]; then
info "Backing up $real_bin → $orig_backup"
sudo cp -a "$real_bin" "$orig_backup"
fi
sudo sed -i "s|^exec \(.*\) \"\\\$@\"|exec \1 --load-extension=\"$EXT_PATH\" \"\\\$@\"|" "$real_bin"
info "✓ $pretty exec line patched with uBlock Origin"
found_any=1
return
fi
fi
# Case 2: Binary or script without a recognisable exec/--load-extension
# line — wrap it directly (no prior LeechBlock installer has run here).
local orig_backup="${real_bin}.orig"
info "Killing running $pretty instances…"
pkill -f "$real_bin" 2>/dev/null || true
pkill -f "$(basename "$real_bin")" 2>/dev/null || true
sleep 1
if [[ ! -f $orig_backup ]]; then
info "Backing up $real_bin → $orig_backup"
sudo cp -a "$real_bin" "$orig_backup"
else
info "Backup already exists: $orig_backup"
fi
info "Replacing $real_bin with uBlock Origin wrapper…"
sudo tee "$real_bin" >/dev/null <<WRAP
#!/usr/bin/env bash
# __UBLOCK_WRAPPER__ — auto-generated by install_ublock.sh
# Original backed up at: $orig_backup
exec "$orig_backup" --load-extension="$EXT_PATH" "\$@"
WRAP
sudo chmod +x "$real_bin"
info "✓ $pretty now always launches with uBlock Origin"
found_any=1
}
info "Detecting installed browsers…"
for bin in "${!BROWSERS[@]}"; do
if command -v "$bin" >/dev/null 2>&1; then
wire_browser_in_place "$bin" "${BROWSERS[$bin]}"
fi
done
echo
if [[ $found_any -eq 1 ]]; then
info "uBlock Origin integration complete."
warn "Chromium will mark it as a developer extension; this is expected for unpacked installs."
else
warn "No supported browsers detected. uBlock Origin package is present at: $EXT_PATH"
echo "Supported (auto-wired): ${!BROWSERS[*]}."
fi
echo
info "Done."

File diff suppressed because one or more lines are too long

View File

@ -1,238 +0,0 @@
#!/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

@ -1,102 +0,0 @@
#!/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

@ -1,149 +0,0 @@
#!/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

@ -1,120 +0,0 @@
#!/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" "$@"
}

View File

@ -1,184 +0,0 @@
#!/usr/bin/env bash
# Browser detection and launcher wiring for install_leechblock.sh.
# Sourced by the installer; inherits its strict mode and info/warn helpers.
#
# detect_browsers, replace_browser_in_place and wire_up_browsers live in ONE
# file on purpose: detect_browsers writes BROWSERS/FIREFOXES, which the other
# two read. Splitting them would make those globals write-only across a file
# boundary — a genuine SC2034 the hook cannot see past (it runs shellcheck
# without -x), and suppressions are forbidden.
detect_browsers() {
# Detect browsers
declare -gA BROWSERS
BROWSERS=(
[chromium]="Chromium"
["google-chrome-stable"]="Google Chrome"
["google-chrome"]="Google Chrome"
["brave-browser"]="Brave"
["vivaldi-stable"]="Vivaldi"
[vivaldi]="Vivaldi"
[opera]="Opera"
["thorium-browser"]="Thorium"
)
declare -gA FIREFOXES
FIREFOXES=(
[firefox]="Firefox"
["firefox-developer-edition"]="Firefox Developer Edition"
[librewolf]="LibreWolf"
)
found_any=0
# Create a user desktop entry
user_apps_dir="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
mkdir -p "$user_apps_dir"
}
# Inject --load-extension into a browser launcher so every launch includes LeechBlock.
# Handles two cases:
# 1) The binary is a shell script with an "exec" line — patch it in-place.
# 2) The binary is a compiled ELF — wrap it with a shell script.
# Follows symlinks only one level to avoid breaking shared wrapper scripts
# (e.g. browser-preexec-wrapper used by multiple browser symlinks).
# Requires sudo for system paths.
replace_browser_in_place() {
local bin="$1"
shift
local pretty="$1"
shift
local bin_path
bin_path=$(command -v "$bin" || true)
[[ -z $bin_path ]] && return
# Resolve symlinks to find the actual file to patch.
# Use readlink -f to get the canonical path.
local real_bin
real_bin=$(readlink -f "$bin_path")
local load_ext_flag="--load-extension=\"$EXT_PATH\""
# If already patched, skip (idempotent)
if grep -q -- "$load_ext_flag" "$real_bin" 2>/dev/null; then
info "$pretty ($bin) already has LeechBlock --load-extension — skipping"
found_any=1
return
fi
# Case 1: Shell script with an exec line — patch the exec line directly.
# This preserves the original script's logic (e.g. basename routing,
# hosts enforcement) and avoids breaking shared wrapper scripts.
if file "$real_bin" 2>/dev/null | grep -qi 'text\|script'; then
if grep -qE '^exec ' "$real_bin"; then
info "Patching exec line in $real_bin to add LeechBlock…"
# Kill running instances so the patched script takes effect
pkill -f "$real_bin" 2>/dev/null || true
sleep 1
# Back up before patching (only once)
local orig_backup="${real_bin}.orig"
if [[ ! -f $orig_backup ]]; then
info "Backing up $real_bin → $orig_backup"
sudo cp -a "$real_bin" "$orig_backup"
fi
# Insert --load-extension right after "exec <command>" on the exec line.
# Matches: exec "$real_bin" "$@" or exec /path/to/bin $FLAGS "$@"
sudo sed -i "s|^exec \(.*\) \"\\\$@\"|exec \1 $load_ext_flag \"\\\$@\"|" "$real_bin"
info "✓ $pretty exec line patched with LeechBlock"
found_any=1
return
fi
fi
# Case 2: Binary or script without a recognisable exec line — wrap it.
local orig_backup="${real_bin}.orig"
# Kill running instances
info "Killing running $pretty instances…"
pkill -f "$real_bin" 2>/dev/null || true
pkill -f "$(basename "$real_bin")" 2>/dev/null || true
sleep 1
# Back up original
if [[ ! -f $orig_backup ]]; then
info "Backing up $real_bin → $orig_backup"
sudo cp -a "$real_bin" "$orig_backup"
else
info "Backup already exists: $orig_backup"
fi
# Write replacement wrapper
info "Replacing $real_bin with LeechBlock wrapper…"
sudo tee "$real_bin" >/dev/null <<WRAP
#!/usr/bin/env bash
# __LEECHBLOCK_WRAPPER__ — auto-generated by install_leechblock.sh
# Original backed up at: $orig_backup
exec "$orig_backup" --load-extension="$EXT_PATH" "\$@"
WRAP
sudo chmod +x "$real_bin"
info "✓ $pretty now always launches with LeechBlock"
found_any=1
}
wire_up_browsers() {
# The Firefox guidance and the closing summary interpolate the install
# layout. Those are set by resolve_version, which the entry script always
# runs first; defaulting them keeps this function callable on its own
# without aborting under `set -u` on an unbound VERSION_DIR.
local version_dir="${VERSION_DIR:-<not yet resolved>}"
local version="${VERSION:-<unknown>}"
local tag="${TAG:-<unknown>}"
info "Detecting installed browsers…"
for bin in "${!BROWSERS[@]}"; do
if command -v "$bin" >/dev/null 2>&1; then
replace_browser_in_place "$bin" "${BROWSERS[$bin]}"
fi
done
# Global on purpose: written and read only within this file, and the entry
# script forwards it to install_firefox_policy as an argument.
ff_found=0
for bin in "${!FIREFOXES[@]}"; do
if command -v "$bin" >/dev/null 2>&1; then
ff_found=1
fi
done
echo
if [[ $found_any -eq 1 ]]; then
info "Chromium-based integration complete. Launch the browser via its '(LeechBlock)' launcher."
warn "Chromium will mark it as a developer extension; this is expected for unpacked installs."
fi
if [[ $ff_found -eq 1 ]]; then
echo
warn "Detected Firefox-based browser(s). Permanent install from GitHub source isn't possible on stable builds due to required signing."
cat <<FF
Options:
1) Install from Mozilla Add-ons (recommended):
https://addons.mozilla.org/firefox/addon/leechblock-ng/
2) For testing with Developer Edition or Nightly, you can set xpinstall.signatures.required=false
and install a built XPI. We'll still keep the downloaded source at:
$version_dir
To load temporarily for testing (session-only), open 'about:debugging#/runtime/this-firefox' and "Load Temporary Add-on…" then select $version_dir/manifest.json.
Tip: Re-run this script with --install-firefox to auto-install from AMO via enterprise policy (requires sudo).
FF
fi
if [[ $found_any -eq 0 && $ff_found -eq 0 ]]; then
warn "No supported browsers detected. We placed the extension at: $version_dir"
echo "Supported (auto-wired): ${!BROWSERS[*]}. Detected Firefox variants will show guidance only."
fi
echo
info "Done. Version: $version (tag $tag) installed under $version_dir"
}

View File

@ -1,36 +0,0 @@
#!/usr/bin/env bash
# Default-configuration seeding for install_leechblock.sh.
# Sourced by the installer; inherits its strict mode and info/warn helpers.
# Reads SCRIPT_DIR, writes the browser LevelDB storage via the Node seeder.
inject_default_config() {
# ── Inject default blocking configuration ─────────────────────────────
# Write default blocking rules directly into Chrome's LevelDB extension
# storage via Node.js (classic-level). This is content-verification-proof:
# we never touch any extension JS file, so Chrome cannot detect tampering.
DEFAULTS_SRC="$SCRIPT_DIR/leechblock_defaults.json"
if [[ -f $DEFAULTS_SRC ]]; then
# Ensure classic-level is available next to this script.
if [[ ! -d "$SCRIPT_DIR/node_modules/classic-level" ]]; then
info "Installing classic-level npm package into $SCRIPT_DIR ..."
npm install --prefix "$SCRIPT_DIR" 2>&1 | grep -v '^npm warn' || true
fi
# Chrome locks its LevelDB files while running — close all Chromium browsers
# so the write succeeds.
pkill -f 'google-chrome|chromium|brave-browser|vivaldi|thorium' 2>/dev/null || true
sleep 1
# Seed defaults into every Chrome/Chromium profile found on this machine.
if node "$SCRIPT_DIR/seed_leechblock_storage.js" "$DEFAULTS_SRC"; then
info "Seeded default LeechBlock settings into browser storage"
else
warn "Could not seed LeechBlock defaults — run manually after install:"
warn " node $SCRIPT_DIR/seed_leechblock_storage.js $DEFAULTS_SRC"
fi
else
warn "leechblock_defaults.json not found at $DEFAULTS_SRC — skipping default config"
fi
}

View File

@ -1,114 +0,0 @@
#!/usr/bin/env bash
# Release-tag resolution and source download for install_leechblock.sh.
# Sourced by the installer; inherits its strict mode and its info/warn/err
# helpers. Reads REPO_OWNER/REPO_NAME/FORCE, writes VERSION/TAG and the
# INSTALL_ROOT layout globals the later phases consume.
get_latest_tag() {
local repo="$1"
local tag
if command -v jq >/dev/null 2>&1; then
tag=$(curl -fsSL "https://api.github.com/repos/${REPO_OWNER}/${repo}/releases/latest" | jq -r '.tag_name // empty' || true)
if [[ -n $tag && $tag != "null" ]]; then
echo "$tag"
return 0
fi
# Fallback: try tags endpoint
tag=$(curl -fsSL "https://api.github.com/repos/${REPO_OWNER}/${repo}/tags?per_page=1" | jq -r '.[0].name // empty' || true)
if [[ -n $tag && $tag != "null" ]]; then
echo "$tag"
return 0
fi
fi
# Fallback: follow redirect for /releases/latest to extract tag
tag=$(curl -fsSLI "https://github.com/${REPO_OWNER}/${repo}/releases/latest" | awk -F'/tag/' '/^location:/I {print $2}' | tr -d '\r\n' || true)
if [[ -n $tag ]]; then
echo "$tag"
return 0
fi
return 1
}
resolve_version() {
if [[ -z $VERSION ]]; then
info "Resolving latest release tag from GitHub…"
if ! VERSION=$(get_latest_tag "$REPO_NAME"); then
err "Failed to determine latest version tag"
exit 1
fi
fi
if [[ ! $VERSION =~ ^v?[0-9]+(\.[0-9]+)*$ ]]; then
warn "Version tag '$VERSION' doesn't look like vX[.Y[.Z]] — continuing anyway."
fi
VERSION=${VERSION#v} # strip leading v for folder names
TAG="v${VERSION}"
XDG_DATA_HOME=${XDG_DATA_HOME:-"$HOME/.local/share"}
INSTALL_ROOT="$XDG_DATA_HOME/leechblockng"
VERSION_DIR="$INSTALL_ROOT/$VERSION"
CURRENT_LINK="$INSTALL_ROOT/current"
}
download_extension() {
if [[ -d $VERSION_DIR && $FORCE -ne 1 ]]; then
info "LeechBlockNG $VERSION already present at $VERSION_DIR (use --force to reinstall)."
else
info "Downloading LeechBlockNG $TAG source from GitHub…"
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
ARCHIVE_URL="https://github.com/${REPO_OWNER}/${REPO_NAME}/archive/refs/tags/${TAG}.tar.gz"
ARCHIVE_FILE="$tmpdir/${REPO_NAME}-${TAG}.tar.gz"
curl -fL --retry 3 -o "$ARCHIVE_FILE" "$ARCHIVE_URL"
info "Extracting…"
mkdir -p "$tmpdir/extract"
tar -xzf "$ARCHIVE_FILE" -C "$tmpdir/extract"
# The archive usually extracts to REPO_NAME-TAG/ …
src_root=$(find "$tmpdir/extract" -maxdepth 1 -type d -name "${REPO_NAME}-*" | head -n1 || true)
[[ -z $src_root ]] && {
err "Could not locate extracted source root"
exit 1
}
# Find the extension manifest (support a couple of common layouts)
manifest_path=$(find "$src_root" -maxdepth 5 -type f -name manifest.json | head -n1 || true)
if [[ -z $manifest_path ]]; then
err "manifest.json not found in the extracted archive. The project layout may have changed."
exit 1
fi
ext_dir=$(dirname "$manifest_path")
mkdir -p "$INSTALL_ROOT"
rm -rf "$VERSION_DIR"
info "Installing to $VERSION_DIR…"
mkdir -p "$VERSION_DIR"
# Copy the extension directory as-is (avoid bringing tests or build scripts)
rsync -a --delete "$ext_dir/" "$VERSION_DIR/" 2>/dev/null || cp -a "$ext_dir/." "$VERSION_DIR/"
# Download jQuery UI (not included in repo — listed in .gitignore)
# The extension's options.html expects:
# jquery-ui/jquery-ui.min.css
# jquery-ui/external/jquery/jquery.js
# jquery-ui/jquery-ui.min.js
info "Downloading jQuery UI…"
jqui_version="1.14.1"
jqui_url="https://jqueryui.com/resources/download/jquery-ui-${jqui_version}.zip"
jqui_zip="$tmpdir/jquery-ui.zip"
curl -fL --retry 3 -o "$jqui_zip" "$jqui_url"
mkdir -p "$tmpdir/jqui-extract"
unzip -q "$jqui_zip" -d "$tmpdir/jqui-extract"
jqui_src=$(find "$tmpdir/jqui-extract" -maxdepth 1 -type d -name "jquery-ui-*" | head -n1 || true)
if [[ -n $jqui_src ]]; then
mkdir -p "$VERSION_DIR/jquery-ui/external/jquery"
cp "$jqui_src/jquery-ui.min.css" "$VERSION_DIR/jquery-ui/" 2>/dev/null || true
cp "$jqui_src/jquery-ui.min.js" "$VERSION_DIR/jquery-ui/" 2>/dev/null || true
cp "$jqui_src/external/jquery/jquery.js" "$VERSION_DIR/jquery-ui/external/jquery/" 2>/dev/null || true
info "✓ jQuery UI ${jqui_version} installed into extension"
else
warn "Could not extract jQuery UI — options page may not work correctly"
fi
ln -sfn "$VERSION_DIR" "$CURRENT_LINK"
fi
}

View File

@ -1,108 +0,0 @@
#!/usr/bin/env bash
# Firefox enterprise-policy installation for install_leechblock.sh.
# Sourced by the installer; inherits its strict mode and info/warn helpers.
# Reads AUTO_FIREFOX; writes policies.json under system paths.
#
# Whether a Firefox-family browser was detected arrives as $1 rather than as
# the ff_found global the pre-split code used: that global is written in
# leechblock_browsers.sh, and reading it here would be a cross-file global the
# linter genuinely cannot resolve (it runs without -x), i.e. a real SC2154
# rather than noise. Suppressions are forbidden, so the value is passed
# explicitly instead.
install_firefox_policy() {
local ff_found="${1:-0}"
# If requested, attempt automatic install on Firefox via enterprise policies
if [[ $AUTO_FIREFOX -eq 1 && $ff_found -eq 1 ]]; then
echo
info "Attempting Firefox auto-install via Enterprise Policies (requires sudo)."
# AMO info
ADDON_ID="leechblockng@proginosko.com"
ADDON_AMO_URL="https://addons.mozilla.org/firefox/downloads/latest/leechblock-ng/latest.xpi"
# Determine policy directories for detected Firefox-like browsers
declare -a POLICY_DIRS
POLICY_DIRS=()
if command -v firefox >/dev/null 2>&1; then
POLICY_DIRS+=("/etc/firefox/policies" "/usr/lib/firefox/distribution")
fi
if command -v firefox-developer-edition >/dev/null 2>&1; then
POLICY_DIRS+=("/etc/firefox-developer-edition/policies" "/usr/lib/firefox-developer-edition/distribution")
fi
if command -v librewolf >/dev/null 2>&1; then
POLICY_DIRS+=("/etc/librewolf/policies" "/usr/lib/librewolf/distribution")
fi
# Generic mozilla path as fallback
POLICY_DIRS+=("/usr/lib/mozilla/distribution")
updated_any=0
for pol_target in "${POLICY_DIRS[@]}"; do
tmp_pol=$(mktemp)
existing="${pol_target}/policies.json"
if sudo test -f "$existing"; then
info "Merging into existing policies.json at $existing"
sudo cp "$existing" "$tmp_pol"
if command -v jq >/dev/null 2>&1; then
merged=$(jq --arg id "$ADDON_ID" --arg url "$ADDON_AMO_URL" '
.policies |= (. // {}) |
.policies.ExtensionSettings |= (. // {}) |
.policies.ExtensionSettings."*" |= (. // {"installation_mode":"allowed"}) |
.policies.ExtensionSettings[$id] |= (. // {}) |
.policies.ExtensionSettings[$id].installation_mode = "force_installed" |
.policies.ExtensionSettings[$id].install_url = $url
' "$tmp_pol") || merged=""
if [[ -n $merged ]]; then
printf '%s\n' "$merged" >"$tmp_pol"
else
warn "jq merge failed; skipping $pol_target"
rm -f "$tmp_pol"
continue
fi
else
warn "jq not available; creating minimal policies.json (existing file will be backed up)."
sudo cp "$existing" "${existing}.bak.$(date +%s)"
cat >"$tmp_pol" <<JSON
{
"policies": {
"ExtensionSettings": {
"*": { "installation_mode": "allowed" },
"$ADDON_ID": {
"installation_mode": "force_installed",
"install_url": "$ADDON_AMO_URL"
}
}
}
}
JSON
fi
else
info "Creating new policies.json at $pol_target"
cat >"$tmp_pol" <<JSON
{
"policies": {
"ExtensionSettings": {
"*": { "installation_mode": "allowed" },
"$ADDON_ID": {
"installation_mode": "force_installed",
"install_url": "$ADDON_AMO_URL"
}
}
}
}
JSON
fi
sudo mkdir -p "$pol_target"
sudo cp "$tmp_pol" "$pol_target/policies.json"
rm -f "$tmp_pol"
updated_any=1
done
if [[ $updated_any -eq 1 ]]; then
info "Firefox policies updated. Restart Firefox/LibreWolf to complete installation of LeechBlock NG."
else
warn "No Firefox policy locations updated. You may not have a supported Firefox installed."
fi
info "Firefox policy updated. Restart Firefox to complete installation of LeechBlock NG."
fi
}

View File

@ -1,42 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
# Function to install guard-lib protection (path watcher + enforcement)
# and the bespoke ratchet-aware unlock script.
# Defined in the same file as its use, immediately above it: a definition
# placed in the entry script was stripped by an autoformat pass three times
# during this campaign.
: "${_MS_PAYLOAD_DIR:=$(cd "$(dirname "${BASH_SOURCE[0]}")/payloads" && pwd)}"
create_config_guard() {
echo ""
echo "2. Installing Config Guard (guard-lib + unlock script)..."
echo "=========================================================="
command -v guardctl >/dev/null 2>&1 || {
echo "Error: guardctl not found on PATH. Set up ~/guard-lib first (run its install.sh)." >&2
exit 1
}
if guardctl file-guard status "$GUARD_NAME" >/dev/null 2>&1; then
echo "✓ guard-lib instance '$GUARD_NAME' already installed (content applied above)"
else
guardctl file-guard install "$GUARD_NAME" --target "$CONFIG_FILE"
echo "✓ Installed guard-lib file-guard '$GUARD_NAME' (canonical snapshot, chattr +i, path watcher, initial enforcement)"
fi
# Obscure name for unlock script - not documented anywhere
local unlock_script="/usr/local/sbin/.sd-sched-mgmt"
# Create unlock script with psychological delay
# The unlock script is a verbatim payload file, not an inline heredoc:
# the original heredoc was quoted, so its body was already literal, and
# inlining it put this lib over the 250-line cap. Same convention as
# lib/payloads/night-lockdown-*.sh.in. Equivalence is proven by hashing
# the emitted file, not by inspection.
cat "$_MS_PAYLOAD_DIR/shutdown-config-unlock.sh.in" >"$unlock_script"
chmod +x "$unlock_script"
# Silently create unlock script - do not announce its existence
}

View File

@ -1,131 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
# Prints guard-lib's canonical path for our instance, or nothing if the
# instance isn't installed yet (first run on this machine).
canonical_config_path() {
if command -v guardctl >/dev/null 2>&1 && guardctl file-guard status "$GUARD_NAME" >/dev/null 2>&1; then
guardctl file-guard canonical-path "$GUARD_NAME"
fi
}
validate_minimum_usage_window() {
local mon_wed_window thu_sun_window
mon_wed_window=$((SCHEDULE_MON_WED_HOUR - SCHEDULE_MORNING_END_HOUR))
thu_sun_window=$((SCHEDULE_THU_SUN_HOUR - SCHEDULE_MORNING_END_HOUR))
local errors=()
if [[ $mon_wed_window -le 0 ]]; then
errors+=("Mon-Wed: morning end (${SCHEDULE_MORNING_END_HOUR}:00) is at or after shutdown (${SCHEDULE_MON_WED_HOUR}:00) — 0 usable hours")
elif [[ $mon_wed_window -lt $MIN_USAGE_HOURS ]]; then
errors+=("Mon-Wed: only ${mon_wed_window}h of usable time (${SCHEDULE_MORNING_END_HOUR}:00–${SCHEDULE_MON_WED_HOUR}:00), need at least ${MIN_USAGE_HOURS}h")
fi
if [[ $thu_sun_window -le 0 ]]; then
errors+=("Thu-Sun: morning end (${SCHEDULE_MORNING_END_HOUR}:00) is at or after shutdown (${SCHEDULE_THU_SUN_HOUR}:00) — 0 usable hours")
elif [[ $thu_sun_window -lt $MIN_USAGE_HOURS ]]; then
errors+=("Thu-Sun: only ${thu_sun_window}h of usable time (${SCHEDULE_MORNING_END_HOUR}:00–${SCHEDULE_THU_SUN_HOUR}:00), need at least ${MIN_USAGE_HOURS}h")
fi
if [[ ${#errors[@]} -gt 0 ]]; then
echo ""
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║ ❌ INVALID SCHEDULE CONFIGURATION ❌ ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
echo ""
echo "The schedule constants do not guarantee at least ${MIN_USAGE_HOURS} hours of"
echo "continuous PC availability. This would cause the PC to shut down"
echo "immediately or very shortly after it becomes usable."
echo ""
for err in "${errors[@]}"; do
echo " ✗ $err"
done
echo ""
echo "Fix: ensure (SHUTDOWN_HOUR - MORNING_END_HOUR) >= ${MIN_USAGE_HOURS} for both windows."
echo " Example: MORNING_END_HOUR=6, SHUTDOWN_HOUR=22 → 16 usable hours ✓"
echo ""
exit 1
fi
}
# Check if trying to make schedule more lenient (later shutdown / earlier morning end)
check_schedule_protection() {
local canonical_config
canonical_config="$(canonical_config_path)"
# Skip check if no canonical config exists yet (first install)
if [[ -z $canonical_config ]] || [[ ! -f $canonical_config ]]; then
return 0
fi
# Load canonical values
local canonical_mon_wed canonical_thu_sun canonical_morning_end
# shellcheck source=/dev/null
source "$canonical_config" 2>/dev/null || return 0
canonical_mon_wed="${MON_WED_HOUR:-}"
canonical_thu_sun="${THU_SUN_HOUR:-}"
canonical_morning_end="${MORNING_END_HOUR:-}"
# If canonical values are empty, skip check
if [[ -z $canonical_mon_wed ]]; then
return 0
fi
if [[ -z $canonical_thu_sun ]]; then
return 0
fi
if [[ -z $canonical_morning_end ]]; then
return 0
fi
local violations=()
# Check if Mon-Wed hour is being made LATER (more lenient)
if [[ $SCHEDULE_MON_WED_HOUR -gt $canonical_mon_wed ]]; then
violations+=("Mon-Wed shutdown: ${canonical_mon_wed}:00 → ${SCHEDULE_MON_WED_HOUR}:00 (later)")
fi
# Check if Thu-Sun hour is being made LATER (more lenient)
if [[ $SCHEDULE_THU_SUN_HOUR -gt $canonical_thu_sun ]]; then
violations+=("Thu-Sun shutdown: ${canonical_thu_sun}:00 → ${SCHEDULE_THU_SUN_HOUR}:00 (later)")
fi
# Check if morning end is being made EARLIER (more lenient - shorter shutdown window)
if [[ $SCHEDULE_MORNING_END_HOUR -lt $canonical_morning_end ]]; then
violations+=("Morning end: 0${canonical_morning_end}:00 → 0${SCHEDULE_MORNING_END_HOUR}:00 (earlier)")
fi
if [[ ${#violations[@]} -gt 0 ]]; then
echo ""
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║ ❌ OPERATION NOT PERMITTED ❌ ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
echo ""
echo "The requested schedule modification has been denied."
echo ""
exit 1
fi
# Making schedule STRICTER is always allowed
local stricter=()
if [[ $SCHEDULE_MON_WED_HOUR -lt $canonical_mon_wed ]]; then
stricter+=("Mon-Wed: ${canonical_mon_wed}:00 → ${SCHEDULE_MON_WED_HOUR}:00 (earlier)")
fi
if [[ $SCHEDULE_THU_SUN_HOUR -lt $canonical_thu_sun ]]; then
stricter+=("Thu-Sun: ${canonical_thu_sun}:00 → ${SCHEDULE_THU_SUN_HOUR}:00 (earlier)")
fi
if [[ $SCHEDULE_MORNING_END_HOUR -gt $canonical_morning_end ]]; then
stricter+=("Morning end: 0${canonical_morning_end}:00 → 0${SCHEDULE_MORNING_END_HOUR}:00 (later)")
fi
if [[ ${#stricter[@]} -gt 0 ]]; then
echo ""
echo "ℹ️ Schedule is being made STRICTER (allowed without unlock):"
for s in "${stricter[@]}"; do
echo " • $s"
done
echo ""
fi
return 0
}

View File

@ -1,238 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
# Function to install the monitor service
install_monitor_service() {
echo ""
echo "7. Installing Shutdown Timer Monitor Service..."
echo "=============================================="
local monitor_script="/usr/local/bin/shutdown-timer-monitor.sh"
local monitor_service="/etc/systemd/system/shutdown-timer-monitor.service"
local monitor_timer="/etc/systemd/system/shutdown-timer-monitor-watchdog.timer"
local monitor_watchdog_service="/etc/systemd/system/shutdown-timer-monitor-watchdog.service"
# Create the monitor script
cat >"$monitor_script" <<'EOF'
#!/bin/bash
# Shutdown timer monitor script
# Watches the day-specific-shutdown timer and re-enables it if disabled
# Also ensures the monitor service itself stays running
set -euo pipefail
LOG_FILE="/var/log/shutdown-timer-monitor.log"
TIMER_NAME="day-specific-shutdown.timer"
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 -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() {
if ! systemctl is-enabled "$TIMER_NAME" &>/dev/null; then
log_message "Timer $TIMER_NAME is not enabled"
return 0
fi
if ! systemctl is-active "$TIMER_NAME" &>/dev/null; then
log_message "Timer $TIMER_NAME is not active"
return 0
fi
if [[ ! -f "/etc/systemd/system/$TIMER_NAME" ]]; then
log_message "Timer unit file missing"
return 0
fi
if [[ ! -f "/etc/systemd/system/$SERVICE_NAME" ]]; then
log_message "Service unit file missing"
return 0
fi
if [[ ! -f "/usr/local/bin/day-specific-shutdown-check.sh" ]]; then
log_message "Check script missing"
return 0
fi
return 1
}
restore_timer() {
log_message "Shutdown timer tampering detected - initiating restoration"
systemctl daemon-reload
if ! systemctl is-enabled "$TIMER_NAME" &>/dev/null; then
log_message "Re-enabling $TIMER_NAME"
systemctl enable "$TIMER_NAME" 2>/dev/null || true
fi
if ! systemctl is-active "$TIMER_NAME" &>/dev/null; then
log_message "Re-starting $TIMER_NAME"
systemctl start "$TIMER_NAME" 2>/dev/null || true
fi
if systemctl is-active "$TIMER_NAME" &>/dev/null; then
log_message "Timer restoration completed successfully"
else
log_message "WARNING: Timer restoration may have failed"
fi
}
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"
if timer_needs_restoration; then
restore_timer
fi
fi
done
else
log_message "busctl not available, falling back to polling"
monitor_with_polling
fi
}
monitor_with_polling() {
log_message "Starting shutdown timer monitoring with polling (interval: ${CHECK_INTERVAL}s)"
while true; do
if timer_needs_restoration; then
restore_timer
fi
wait_seconds "$CHECK_INTERVAL"
done
}
start_monitoring() {
log_message "=== Shutdown Timer Monitor Started ==="
log_message "Monitoring timer: $TIMER_NAME"
log_message "Monitoring service: $SERVICE_NAME"
if timer_needs_restoration; then
log_message "Initial check: Timer needs restoration"
restore_timer
else
log_message "Initial check: Timer is properly configured"
fi
if command -v busctl &>/dev/null; then
monitor_with_dbus
else
log_message "busctl not available, falling back to polling"
monitor_with_polling
fi
}
start_monitoring
EOF
chmod +x "$monitor_script"
echo "✓ Created monitor script: $monitor_script"
# Create the monitor service with RefuseManualStop to prevent manual stopping
cat >"$monitor_service" <<'EOF'
[Unit]
Description=Shutdown Timer Monitor and Auto-Restore Service
After=network-online.target day-specific-shutdown.timer
Wants=network-online.target
# Make it hard to stop - refuse manual stop/restart
RefuseManualStop=true
RefuseManualStart=false
[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/shutdown-timer-monitor.sh
Restart=always
RestartSec=5
# Restart even on success exit
RestartForceExitStatus=0 1 2 SIGTERM SIGKILL
StandardOutput=journal
StandardError=journal
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
NoNewPrivileges=false
PrivateTmp=true
MemoryMax=50M
CPUQuota=10%
[Install]
WantedBy=multi-user.target
EOF
echo "✓ Created monitor service: $monitor_service"
# Create a watchdog timer that ensures the monitor stays running
cat >"$monitor_watchdog_service" <<'EOF'
[Unit]
Description=Watchdog for Shutdown Timer Monitor
After=multi-user.target
[Service]
Type=oneshot
ExecStart=/bin/bash -c 'systemctl is-active shutdown-timer-monitor.service || systemctl start shutdown-timer-monitor.service'
ExecStart=/bin/bash -c 'systemctl is-active day-specific-shutdown.timer || systemctl start day-specific-shutdown.timer'
EOF
echo "✓ Created watchdog service: $monitor_watchdog_service"
cat >"$monitor_timer" <<'EOF'
[Unit]
Description=Watchdog Timer for Shutdown Timer Monitor
After=multi-user.target
[Timer]
OnBootSec=60
OnUnitActiveSec=300
Persistent=true
[Install]
WantedBy=timers.target
EOF
echo "✓ Created watchdog timer: $monitor_timer"
# Reload and enable everything
systemctl daemon-reload
systemctl enable shutdown-timer-monitor.service
systemctl enable shutdown-timer-monitor-watchdog.timer
systemctl start shutdown-timer-monitor.service
systemctl start shutdown-timer-monitor-watchdog.timer
echo "✓ Enabled and started shutdown-timer-monitor.service"
echo "✓ Enabled and started shutdown-timer-monitor-watchdog.timer"
}

View File

@ -1,239 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
# Function to create the time-boxed override manager (event exceptions like
# watching a live match that runs past the normal shutdown window). Overrides
# are absolute-epoch-bound so they expire on their own; the friction (typed
# confirmation + delay) lives entirely in this CLI, matching the existing
# unlock script's philosophy for the permanent schedule ratchet.
create_override_manager_script() {
echo ""
echo "9. Creating Shutdown Override Manager..."
echo "========================================"
local overrides_file="/etc/shutdown-schedule-overrides.conf"
local log_file="/var/log/shutdown-schedule-overrides.log"
local manager_script="/usr/local/bin/shutdown-override-manager.sh"
touch "$overrides_file"
# World-readable (like shutdown-schedule.conf): the i3blocks countdown
# script runs as the regular user and needs to read active overrides to
# display them. Only root can write to it (via this manager script).
chmod 644 "$overrides_file"
touch "$log_file"
chmod 600 "$log_file"
cat >"$manager_script" <<'EOF'
#!/bin/bash
# Shutdown Override Manager
# Registers time-boxed exceptions to the day-specific-shutdown schedule, e.g.
# to watch a live event that runs past the normal shutdown window. Entries are
# absolute-epoch-bound and expire on their own - there is nothing to re-lock.
set -euo pipefail
OVERRIDES_FILE="/etc/shutdown-schedule-overrides.conf"
LOG_FILE="/var/log/shutdown-schedule-overrides.log"
MAX_OVERRIDE_HOURS=12
MAX_ACTIVE_OVERRIDES=3
MIN_REASON_LEN=10
CONFIRM_PHRASE="I am deliberately bypassing my shutdown protection"
DELAY_SECONDS=30
log() {
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" >&2
}
require_root() {
if [[ $EUID -ne 0 ]]; then
echo "This command requires sudo." >&2
exit 1
fi
}
now_epoch() {
printf '%(%s)T' -1
}
prune_and_read() {
# Prints kept (non-expired) lines; overwrites OVERRIDES_FILE with them.
local now kept=""
now=$(now_epoch)
if [[ -f "$OVERRIDES_FILE" ]]; then
local start_epoch end_epoch created reason
while IFS='|' read -r start_epoch end_epoch created reason; do
[[ -n "$start_epoch" ]] || continue
[[ "$end_epoch" -lt "$now" ]] && continue
kept+="${start_epoch}|${end_epoch}|${created}|${reason}"$'\n'
done <"$OVERRIDES_FILE"
fi
printf '%s' "$kept" >"$OVERRIDES_FILE"
printf '%s' "$kept"
}
cmd_list() {
local kept
kept="$(prune_and_read)"
if [[ -z "$kept" ]]; then
echo "No active or upcoming overrides."
return 0
fi
echo "Active/upcoming overrides:"
local i=1
local start_epoch end_epoch created reason
while IFS='|' read -r start_epoch end_epoch created reason; do
[[ -n "$start_epoch" ]] || continue
printf ' [%d] %s -> %s (reason: %s)\n' \
"$i" \
"$(date -d "@$start_epoch" '+%Y-%m-%d %H:%M')" \
"$(date -d "@$end_epoch" '+%Y-%m-%d %H:%M')" \
"$reason"
i=$((i + 1))
done <<<"$kept"
}
cmd_remove() {
require_root
local target="${1:-}"
[[ -n "$target" ]] || { echo "Usage: $0 remove <line-number-from-list>" >&2; exit 1; }
local kept
kept="$(prune_and_read)"
if [[ -z "$kept" ]]; then
echo "No overrides to remove." >&2
exit 1
fi
local new_content="" i=1 start_epoch end_epoch created reason removed=false
while IFS='|' read -r start_epoch end_epoch created reason; do
[[ -n "$start_epoch" ]] || continue
if [[ "$i" == "$target" ]]; then
removed=true
else
new_content+="${start_epoch}|${end_epoch}|${created}|${reason}"$'\n'
fi
i=$((i + 1))
done <<<"$kept"
if [[ "$removed" != true ]]; then
echo "No override at index $target" >&2
exit 1
fi
printf '%s' "$new_content" >"$OVERRIDES_FILE"
log "Removed override index $target (by $(logname 2>/dev/null || echo unknown))"
echo "✓ Removed override $target"
}
cmd_add() {
require_root
local start_str="${1:-}" end_str="${2:-}"
local reason="${*:3}"
if [[ -z "$start_str" ]] || [[ -z "$end_str" ]] || [[ -z "$reason" ]]; then
echo "Usage: $0 add '<start datetime>' '<end datetime>' <reason>" >&2
echo "Example: $0 add '2026-07-10 21:30' '2026-07-11 00:30' 'World Cup match'" >&2
exit 1
fi
if [[ "${#reason}" -lt "$MIN_REASON_LEN" ]]; then
echo "Reason must be at least $MIN_REASON_LEN characters." >&2
exit 1
fi
local start_epoch end_epoch
start_epoch=$(date -d "$start_str" +%s) || { echo "Could not parse start datetime: $start_str" >&2; exit 1; }
end_epoch=$(date -d "$end_str" +%s) || { echo "Could not parse end datetime: $end_str" >&2; exit 1; }
if [[ "$end_epoch" -le "$start_epoch" ]]; then
echo "End must be after start." >&2
exit 1
fi
local duration_hours=$(( (end_epoch - start_epoch) / 3600 ))
if [[ "$duration_hours" -gt "$MAX_OVERRIDE_HOURS" ]]; then
echo "Override duration (${duration_hours}h) exceeds the max of ${MAX_OVERRIDE_HOURS}h." >&2
exit 1
fi
local kept active_count
kept="$(prune_and_read)"
active_count=$(grep -c . <<<"$kept" 2>/dev/null || true)
active_count="${active_count:-0}"
if [[ "$active_count" -ge "$MAX_ACTIVE_OVERRIDES" ]]; then
echo "Already at the max of $MAX_ACTIVE_OVERRIDES active/upcoming overrides. Remove one first." >&2
cmd_list
exit 1
fi
echo ""
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║ SHUTDOWN SCHEDULE OVERRIDE REQUEST ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
echo ""
echo "This suspends the shutdown enforcement you built for yourself during:"
echo " $(date -d "@$start_epoch" '+%Y-%m-%d %H:%M') -> $(date -d "@$end_epoch" '+%Y-%m-%d %H:%M') (${duration_hours}h)"
echo " Reason: $reason"
echo ""
echo "Current overrides:"
cmd_list
echo ""
echo "To confirm this is a deliberate, one-off exception (not just wanting to"
echo "stay up), type the following phrase exactly:"
echo ""
echo " $CONFIRM_PHRASE"
echo ""
read -r -p "> " typed_phrase
if [[ "$typed_phrase" != "$CONFIRM_PHRASE" ]]; then
echo "Phrase did not match. Aborting." >&2
log "ABORTED: confirmation phrase mismatch for window $start_str -> $end_str"
exit 1
fi
echo ""
echo "Waiting $DELAY_SECONDS seconds before committing (Ctrl+C to cancel)..."
local i
for ((i = DELAY_SECONDS; i > 0; i--)); do
printf "\r Waiting: %2d seconds remaining..." "$i"
sleep 1
done
echo ""
local created
created=$(now_epoch)
printf '%s|%s|%s|%s\n' "$start_epoch" "$end_epoch" "$created" "$reason" >>"$OVERRIDES_FILE"
log "ADDED override by $(logname 2>/dev/null || echo unknown) from TTY $(tty 2>/dev/null || echo unknown): $start_str -> $end_str (reason: $reason)"
echo "✓ Override registered: $(date -d "@$start_epoch" '+%Y-%m-%d %H:%M') -> $(date -d "@$end_epoch" '+%Y-%m-%d %H:%M')"
}
case "${1:-}" in
add)
shift
cmd_add "$@"
;;
list)
cmd_list
;;
remove)
shift
cmd_remove "$@"
;;
*)
echo "Shutdown Override Manager"
echo "Usage: $0 {add|list|remove}"
echo ""
echo " add '<start>' '<end>' <reason> Register a time-boxed exception"
echo " list Show active/upcoming overrides"
echo " remove <index> Cancel an override (no friction)"
echo ""
cmd_list
;;
esac
EOF
chmod +x "$manager_script"
echo "✓ Created override manager: $manager_script"
echo "✓ Overrides file: $overrides_file (root-only)"
echo "✓ Override log: $log_file"
}

View File

@ -1,182 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
# Function to show usage
show_usage() {
echo "Day-Specific Auto-Shutdown Setup for Arch Linux"
echo "==============================================="
echo "Usage: $0 [enable|status]"
echo ""
echo "Commands:"
echo " enable - Set up automatic shutdown with day-specific windows (default)"
echo " status - Show current status"
echo ""
echo "Shutdown Schedule:"
echo " Monday-Wednesday: ${SCHEDULE_MON_WED_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00"
echo " Thursday-Sunday: ${SCHEDULE_THU_SUN_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00"
echo ""
echo "NOTE: There is no 'disable' option. This is intentional."
echo " The shutdown timer is protected by a monitor service."
echo ""
}
# Function to check and request sudo privileges
check_sudo() {
if [[ $EUID -ne 0 ]]; then
echo "This script requires sudo privileges to manage systemd services."
echo "Requesting sudo access..."
exec sudo "$0" "$@"
fi
}
# Function to show current status
show_current_status() {
echo "Day-Specific Auto-Shutdown Status"
echo "================================="
echo "Current Date: $(date)"
echo "User: $ACTUAL_USER"
echo ""
local timer_exists=false
# Check if files exist
if [[ -f "/etc/systemd/system/day-specific-shutdown.timer" ]]; then
timer_exists=true
echo "✓ Timer file exists"
else
echo "✗ Timer file missing"
fi
if [[ -f "/etc/systemd/system/day-specific-shutdown.service" ]]; then
echo "✓ Service file exists"
else
echo "✗ Service file missing"
fi
if [[ -f "/usr/local/bin/day-specific-shutdown-manager.sh" ]]; then
echo "✓ Management script exists"
else
echo "✗ Management script missing"
fi
if [[ -f "/usr/local/bin/shutdown-timer-monitor.sh" ]]; then
echo "✓ Monitor script exists"
else
echo "✗ Monitor script missing"
fi
echo ""
# Check systemd status
if $timer_exists; then
if systemctl is-enabled day-specific-shutdown.timer &>/dev/null; then
echo "✓ Timer is enabled"
if systemctl is-active day-specific-shutdown.timer &>/dev/null; then
echo "✓ Timer is active"
echo ""
echo "Next scheduled shutdown check:"
systemctl list-timers day-specific-shutdown.timer --no-pager 2>/dev/null | grep day-specific-shutdown || echo "Timer information not available"
else
echo "✗ Timer is not active"
fi
else
echo "✗ Timer is not enabled"
fi
else
echo "Status: NOT CONFIGURED"
fi
echo ""
# Check monitor service status
echo "Monitor Service Status:"
if systemctl is-enabled shutdown-timer-monitor.service &>/dev/null; then
echo "✓ Monitor is enabled"
if systemctl is-active shutdown-timer-monitor.service &>/dev/null; then
echo "✓ Monitor is active (will re-enable timer if disabled)"
else
echo "✗ Monitor is not active"
fi
else
echo "✗ Monitor is not enabled"
fi
echo ""
# Check config file protection status (via guard-lib)
echo "Config File Protection Status:"
local canonical_file
canonical_file="$(canonical_config_path)"
if [[ -f $CONFIG_FILE ]]; then
echo "✓ Config file exists"
# Check immutable attribute
if lsattr "$CONFIG_FILE" 2>/dev/null | grep -q '^....i'; then
echo "✓ Config file is immutable (chattr +i)"
else
echo "✗ Config file is NOT immutable"
fi
else
echo "✗ Config file missing"
fi
if [[ -n $canonical_file ]] && [[ -f $canonical_file ]]; then
echo "✓ Canonical copy exists ($canonical_file)"
else
echo "✗ Canonical copy missing (guard-lib instance not installed?)"
fi
if systemctl is-enabled "guard-file@${GUARD_NAME}.path" &>/dev/null; then
echo "✓ Config path watcher is enabled"
if systemctl is-active "guard-file@${GUARD_NAME}.path" &>/dev/null; then
echo "✓ Config path watcher is active"
else
echo "✗ Config path watcher is not active"
fi
else
echo "✗ Config path watcher is not enabled"
fi
echo ""
echo "Shutdown Schedule:"
echo " Monday-Wednesday: ${SCHEDULE_MON_WED_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00"
echo " Thursday-Sunday: ${SCHEDULE_THU_SUN_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00"
echo ""
echo "NOTE: The shutdown timer is protected by a monitor service."
echo " If you try to disable the timer, it will be automatically re-enabled."
echo ""
echo "NOTE: The config file is protected by:"
echo " - Immutable attribute (chattr +i)"
echo " - Canonical copy that auto-restores on modification"
echo " - Path watcher service"
echo ""
echo "Active Overrides:"
if command -v /usr/local/bin/shutdown-override-manager.sh >/dev/null 2>&1; then
/usr/local/bin/shutdown-override-manager.sh list | sed 's/^/ /'
else
echo " (override manager not installed)"
fi
echo ""
}
# Display the shutdown schedule (used in multiple places)
print_shutdown_schedule() {
# Convert 24h to 12h format for display
local mon_wed_12h thu_sun_12h morning_12h
if [[ $SCHEDULE_MON_WED_HOUR -gt 12 ]]; then
mon_wed_12h="$((SCHEDULE_MON_WED_HOUR - 12)):00 PM"
else
mon_wed_12h="${SCHEDULE_MON_WED_HOUR}:00 AM"
fi
if [[ $SCHEDULE_THU_SUN_HOUR -gt 12 ]]; then
thu_sun_12h="$((SCHEDULE_THU_SUN_HOUR - 12)):00 PM"
else
thu_sun_12h="${SCHEDULE_THU_SUN_HOUR}:00 AM"
fi
morning_12h="${SCHEDULE_MORNING_END_HOUR}:00 AM"
echo "Shutdown Schedule:"
echo " Monday-Wednesday: ${SCHEDULE_MON_WED_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00 (${mon_wed_12h} to ${morning_12h})"
echo " Thursday-Sunday: ${SCHEDULE_THU_SUN_HOUR}:00-0${SCHEDULE_MORNING_END_HOUR}:00 (${thu_sun_12h} to ${morning_12h})"
}

View File

@ -1,157 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
# Function to create smart shutdown check script
create_shutdown_check_script() {
echo ""
echo "6. Creating Smart Shutdown Check Script..."
echo "========================================"
local check_script="/usr/local/bin/day-specific-shutdown-check.sh"
cat >"$check_script" <<'EOF'
#!/bin/bash
# Smart day-specific shutdown check script
# Reads shutdown windows from /etc/shutdown-schedule.conf
CONFIG_FILE="/etc/shutdown-schedule.conf"
OVERRIDES_FILE="/etc/shutdown-schedule-overrides.conf"
# Time-boxed exceptions (e.g. watching a live event that runs past the normal
# shutdown window) are registered via shutdown-override-manager.sh, which
# appends "start_epoch|end_epoch|created_epoch|reason" lines to OVERRIDES_FILE.
# Entries are absolute-epoch-bound so they expire on their own; this function
# prunes stale lines and returns success (skip shutdown) if now falls inside
# any remaining window.
check_active_override() {
[[ -f "$OVERRIDES_FILE" ]] || return 1
local now
now=$(printf '%(%s)T' -1)
local kept=""
local active=false
local active_reason=""
local start_epoch end_epoch _created reason
while IFS='|' read -r start_epoch end_epoch _created reason; do
[[ -n "$start_epoch" ]] || continue
if [[ $end_epoch -lt $now ]]; then
continue # expired, drop it
fi
kept+="${start_epoch}|${end_epoch}|${_created}|${reason}"$'\n'
if [[ $now -ge $start_epoch ]] && [[ $now -le $end_epoch ]]; then
active=true
active_reason="$reason"
fi
done <"$OVERRIDES_FILE"
printf '%s' "$kept" >"$OVERRIDES_FILE"
if [[ $active == true ]]; then
logger -t day-specific-shutdown "Active override in effect (reason: ${active_reason}) - skipping shutdown check"
return 0
fi
return 1
}
if check_active_override; then
exit 0
fi
# Load config
if [[ ! -f "$CONFIG_FILE" ]]; then
logger -t day-specific-shutdown "ERROR: Config file $CONFIG_FILE not found"
exit 1
fi
# shellcheck source=/dev/null
source "$CONFIG_FILE"
# Validate config
if [[ -z "${MON_WED_HOUR:-}" ]]; then
logger -t day-specific-shutdown "ERROR: Config file missing required variables"
exit 1
fi
if [[ -z "${THU_SUN_HOUR:-}" ]]; then
logger -t day-specific-shutdown "ERROR: Config file missing required variables"
exit 1
fi
if [[ -z "${MORNING_END_HOUR:-}" ]]; then
logger -t day-specific-shutdown "ERROR: Config file missing required variables"
exit 1
fi
# Get current time and day (fork-free bash builtins)
current_hour=$(printf '%(%H)T' -1)
current_minute=$(printf '%(%M)T' -1)
current_time_minutes=$((10#$current_hour * 60 + 10#$current_minute))
day_of_week=$(printf '%(%u)T' -1) # 1=Monday, 7=Sunday
day_name=$(printf '%(%A)T' -1)
# Calculate minute thresholds from config
mon_wed_minutes=$((MON_WED_HOUR * 60))
thu_sun_minutes=$((THU_SUN_HOUR * 60))
morning_end_minutes=$((MORNING_END_HOUR * 60))
logger -t day-specific-shutdown "Checking shutdown conditions at $(printf '%(%Y-%m-%d %H:%M:%S)T' -1) - Day: $day_name ($day_of_week), Time: $current_hour:$current_minute"
# Determine if we should shutdown based on day and time
should_shutdown=false
if [[ $day_of_week -ge 1 ]] && [[ $day_of_week -le 3 ]]; then
# Monday (1), Tuesday (2), Wednesday (3)
shutdown_start=$mon_wed_minutes
logger -t day-specific-shutdown "Today is $day_name - checking ${MON_WED_HOUR}:00-0${MORNING_END_HOUR}:00 window"
if [[ $current_time_minutes -ge $shutdown_start ]] || [[ $current_time_minutes -lt $morning_end_minutes ]]; then
should_shutdown=true
if [[ $current_time_minutes -ge $shutdown_start ]]; then
logger -t day-specific-shutdown "Time $current_hour:$current_minute is within evening shutdown window (${MON_WED_HOUR}:00-23:59)"
else
logger -t day-specific-shutdown "Time $current_hour:$current_minute is within morning shutdown window (00:00-0${MORNING_END_HOUR}:00)"
fi
else
logger -t day-specific-shutdown "Time $current_hour:$current_minute is outside shutdown window (${MON_WED_HOUR}:00-0${MORNING_END_HOUR}:00)"
fi
else
# Thursday (4), Friday (5), Saturday (6), Sunday (7)
shutdown_start=$thu_sun_minutes
logger -t day-specific-shutdown "Today is $day_name - checking ${THU_SUN_HOUR}:00-0${MORNING_END_HOUR}:00 window"
if [[ $current_time_minutes -ge $shutdown_start ]] || [[ $current_time_minutes -lt $morning_end_minutes ]]; then
should_shutdown=true
if [[ $current_time_minutes -ge $shutdown_start ]]; then
logger -t day-specific-shutdown "Time $current_hour:$current_minute is within evening shutdown window (${THU_SUN_HOUR}:00-23:59)"
else
logger -t day-specific-shutdown "Time $current_hour:$current_minute is within morning shutdown window (00:00-0${MORNING_END_HOUR}:00)"
fi
else
logger -t day-specific-shutdown "Time $current_hour:$current_minute is outside shutdown window (${THU_SUN_HOUR}:00-0${MORNING_END_HOUR}:00)"
fi
fi
if [[ $should_shutdown == true ]]; then
printf '%(%Y-%m-%d %H:%M:%S)T: Executing shutdown - current time %s:%s is within shutdown window for %s\n' -1 "$current_hour" "$current_minute" "$day_name"
logger -t day-specific-shutdown "Executing scheduled shutdown at $(printf '%(%Y-%m-%d %H:%M:%S)T' -1)"
# Night lockdown instead of power-off. This machine is a 24/7 home server
# (Gitea, the Caddy TLS edge, SyncYomi, the personal website, Open WebUI,
# Joplin, dufs, ollama, dnsmasq, wg-quick@wg0, nftables, sshd). Powering off
# took every server down with it. The lockdown action tears down the user GUI
# and masks the TTY login surface so the machine is unusable from the keyboard,
# while all servers keep running. See setup_night_lockdown.sh. The morning
# unlock (night-lockdown-unlock.timer) restores the desktop at 05:00, so the
# old rtcwake/hibernate wake-for-alarm path is no longer needed — the machine
# simply stays on all night. DRY_RUN passes through so
# `DRY_RUN=1 day-specific-shutdown-check.sh` exercises the path without locking.
logger -t day-specific-shutdown "Entering night lockdown (servers stay up) via /usr/local/bin/night-lockdown-enter.sh"
DRY_RUN="${DRY_RUN:-}" /usr/local/bin/night-lockdown-enter.sh
else
printf '%(%Y-%m-%d %H:%M:%S)T: Skipping shutdown - not within shutdown window for %s (current: %s:%s)\n' -1 "$day_name" "$current_hour" "$current_minute"
logger -t day-specific-shutdown "Skipped shutdown - not within shutdown window for $day_name (current: $current_hour:$current_minute)"
fi
EOF
chmod +x "$check_script"
echo "✓ Created smart shutdown check script: $check_script"
}

View File

@ -1,231 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
# Function to test the setup
test_setup() {
echo ""
echo "8. Testing Setup..."
echo "=================="
echo "Service files:"
if [[ -f "/etc/systemd/system/day-specific-shutdown.service" ]]; then
echo "✓ Service file exists"
else
echo "✗ Service file missing"
fi
if [[ -f "/etc/systemd/system/day-specific-shutdown.timer" ]]; then
echo "✓ Timer file exists"
else
echo "✗ Timer file missing"
fi
if [[ -f "/etc/systemd/system/shutdown-timer-monitor.service" ]]; then
echo "✓ Monitor service file exists"
else
echo "✗ Monitor service file missing"
fi
echo ""
echo "Timer status:"
if systemctl is-enabled day-specific-shutdown.timer &>/dev/null; then
echo "✓ Timer is enabled"
else
echo "✗ Timer is not enabled"
fi
if systemctl is-active day-specific-shutdown.timer &>/dev/null; then
echo "✓ Timer is active"
else
echo "✗ Timer is not active"
fi
echo ""
echo "Monitor status:"
if systemctl is-enabled shutdown-timer-monitor.service &>/dev/null; then
echo "✓ Monitor is enabled"
else
echo "✗ Monitor is not enabled"
fi
if systemctl is-active shutdown-timer-monitor.service &>/dev/null; then
echo "✓ Monitor is active"
else
echo "✗ Monitor is not active"
fi
echo ""
echo "Watchdog timer status:"
if systemctl is-enabled shutdown-timer-monitor-watchdog.timer &>/dev/null; then
echo "✓ Watchdog timer is enabled"
else
echo "✗ Watchdog timer is not enabled"
fi
if systemctl is-active shutdown-timer-monitor-watchdog.timer &>/dev/null; then
echo "✓ Watchdog timer is active"
else
echo "✗ Watchdog timer is not active"
fi
echo ""
echo "Config file protection status:"
local canonical_file
canonical_file="$(canonical_config_path)"
if [[ -f $CONFIG_FILE ]]; then
echo "✓ Config file exists"
if lsattr "$CONFIG_FILE" 2>/dev/null | grep -q '^....i'; then
echo "✓ Config file is immutable"
else
echo "✗ Config file is NOT immutable"
fi
else
echo "✗ Config file missing"
fi
if [[ -n $canonical_file ]] && [[ -f $canonical_file ]]; then
echo "✓ Canonical copy exists"
else
echo "✗ Canonical copy missing"
fi
if systemctl is-enabled "guard-file@${GUARD_NAME}.path" &>/dev/null; then
echo "✓ Config guard path watcher is enabled"
else
echo "✗ Config guard path watcher is not enabled"
fi
if systemctl is-active "guard-file@${GUARD_NAME}.path" &>/dev/null; then
echo "✓ Config guard path watcher is active"
else
echo "✗ Config guard path watcher is not active"
fi
echo ""
echo "Next scheduled checks:"
if ! systemctl list-timers day-specific-shutdown.timer --no-pager 2>/dev/null | head -5 | grep day-specific-shutdown; then
echo "Timer information not available"
fi
}
# Function to show final instructions
show_instructions() {
echo ""
echo "================================================="
echo "Day-Specific Auto-Shutdown Setup Complete"
echo "================================================="
echo "Summary:"
echo "✓ Systemd service created (/etc/systemd/system/day-specific-shutdown.service)"
echo "✓ Systemd timer created (/etc/systemd/system/day-specific-shutdown.timer)"
echo "✓ Management script created (/usr/local/bin/day-specific-shutdown-manager.sh)"
echo "✓ Smart check script created (/usr/local/bin/day-specific-shutdown-check.sh)"
echo "✓ Timer enabled and started"
echo "✓ Monitor service installed (protects timer from being disabled)"
echo "✓ Watchdog timer installed (restarts monitor if stopped)"
echo "✓ Config file protected (immutable + path watcher + canonical copy)"
echo ""
print_shutdown_schedule
echo ""
echo "Management commands:"
echo " sudo day-specific-shutdown-manager.sh status - Check status"
echo " sudo day-specific-shutdown-manager.sh logs - View shutdown logs"
echo ""
echo "How it works:"
echo "• Timer checks every 30 minutes during potential shutdown windows"
echo "• Smart logic determines shutdown eligibility based on day and time"
echo "• Monitor service watches the timer and re-enables it if disabled"
echo "• Watchdog timer restarts the monitor every 60 seconds if stopped"
echo "• Monitor has RefuseManualStop=true to prevent easy stopping"
echo "• Config file is protected by multiple security layers"
echo "• There is NO disable option - this is intentional for digital wellbeing"
echo ""
echo "WARNING: This will automatically shutdown your PC during designated hours."
echo "Make sure to save your work before the shutdown windows!"
echo ""
}
# Function to prompt for confirmation
confirm_setup() {
echo ""
echo "WARNING: Day-Specific Auto-Shutdown Confirmation"
echo "==============================================="
echo "This will set up your PC to automatically shutdown during specific time windows."
echo ""
print_shutdown_schedule
echo ""
echo "Important considerations:"
echo "- Any unsaved work will be lost during shutdown windows"
echo "- Running processes will be terminated"
echo "- Downloads/uploads in progress will be interrupted"
echo "- You'll need to manually power on your PC each day"
echo "- Timer checks every 30 minutes during potential shutdown windows"
echo "- There is NO disable option - this is protected by a monitor service"
echo ""
read -r -p "Do you want to proceed? (y/N): " confirm
case "$confirm" in
[yY] | [yY][eE][sS])
echo "Proceeding with setup..."
return 0
;;
*)
echo "Setup cancelled."
exit 0
;;
esac
}
# Main execution flow for enable
enable_midnight_shutdown() {
echo "Day-Specific Auto-Shutdown Setup for Arch Linux"
echo "==============================================="
echo "Current Date: $(date)"
echo "User: $ACTUAL_USER"
echo "Target user: $ACTUAL_USER"
echo "User home: $USER_HOME"
# Check if trying to cheat by making schedule more lenient
check_schedule_protection
# Confirm setup
confirm_setup
# Create config file (shared with i3blocks countdown script)
create_shutdown_config
# Create config guard (path watcher, enforcement, unlock script)
create_config_guard
# Create systemd files
create_shutdown_service
create_shutdown_timer
create_management_script
create_shutdown_check_script
create_override_manager_script
# Enable and start timer
enable_timer
# Install monitor service (protects timer from being disabled)
install_monitor_service
# Test setup
test_setup
# NOTE: this used to `chattr +i` its own source ("lock this setup script so
# values + checks can't be silently edited"). Do NOT reintroduce that: making
# a git-tracked file immutable breaks git. pre-commit clears unstaged changes
# with `git checkout -- .`; that unlink fails with "Operation not permitted"
# on an immutable file, the context manager aborts, and the restore never
# runs — silently reverting your OTHER unstaged edits on every commit/push.
#
# Enforcement does not depend on it: /etc/shutdown-schedule.conf is chattr +i,
# guard-lib keeps a canonical snapshot + path watcher that re-enforces it, the
# monitor service protects the timer, and screen_locker's ratchet only accepts
# same-or-stricter values. Editing this source changes nothing until setup is
# re-run as root, which rewrites the guarded config anyway.
# Show instructions
show_instructions
}

View File

@ -1,247 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
# Function to create/update shutdown schedule config file (shared with
# i3blocks countdown). Mechanical protection (canonical snapshot, chattr,
# path watcher) is guard-lib's job via create_config_guard() below; this
# function only decides what content should exist.
create_shutdown_config() {
echo ""
echo "1. Creating Shutdown Schedule Config..."
echo "======================================="
local new_content
new_content="$(
cat <<EOF
# Shutdown schedule configuration
# This file is managed by setup_midnight_shutdown.sh
# Used by: day-specific-shutdown-check.sh, shutdown_countdown.sh (i3blocks)
#
# WARNING: This file is protected by guard-lib (guardctl): immutable
# attribute, a canonical copy, and a path watcher that auto-restores it
# if modified outside the sanctioned unlock flow.
# Shutdown hour for Monday-Wednesday (24-hour format)
MON_WED_HOUR=${SCHEDULE_MON_WED_HOUR}
# Shutdown hour for Thursday-Sunday (24-hour format)
THU_SUN_HOUR=${SCHEDULE_THU_SUN_HOUR}
# Morning end hour (shutdown window ends at this hour)
MORNING_END_HOUR=${SCHEDULE_MORNING_END_HOUR}
EOF
)"
if guardctl file-guard status "$GUARD_NAME" >/dev/null 2>&1; then
# Already installed and this content already passed
# check_schedule_protection's ratchet check above - apply it
# directly, canonical first then target (same race-avoidance
# order adjust_shutdown_schedule.sh uses), then re-lock both.
local canonical_file
canonical_file="$(guardctl file-guard canonical-path "$GUARD_NAME")"
chattr -i "$canonical_file" 2>/dev/null || true
chattr -i "$CONFIG_FILE" 2>/dev/null || true
echo "$new_content" >"$canonical_file"
chmod 644 "$canonical_file"
chattr +i "$canonical_file" || echo "⚠ Warning: Could not set immutable attribute on $canonical_file"
echo "$new_content" >"$CONFIG_FILE"
chmod 644 "$CONFIG_FILE"
chattr +i "$CONFIG_FILE" || echo "⚠ Warning: Could not set immutable attribute on $CONFIG_FILE"
echo "✓ Updated config and canonical copy: $CONFIG_FILE"
else
# First install: guard-lib's install snapshots this content as
# the canonical copy, so just write the plain file here.
echo "$new_content" >"$CONFIG_FILE"
chmod 644 "$CONFIG_FILE"
echo "✓ Created shutdown schedule config: $CONFIG_FILE"
fi
}
# Function to create the shutdown service
create_shutdown_service() {
echo ""
echo "3. Creating Systemd Shutdown Service..."
echo "======================================"
local service_file="/etc/systemd/system/day-specific-shutdown.service"
cat >"$service_file" <<'EOF'
[Unit]
Description=Automatic PC shutdown with day-specific time windows
DefaultDependencies=false
Before=shutdown.target reboot.target halt.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/day-specific-shutdown-check.sh
TimeoutStartSec=0
StandardOutput=journal
StandardError=journal
EOF
echo "✓ Created systemd service: $service_file"
}
# Function to create the shutdown timer
create_shutdown_timer() {
echo ""
echo "4. Creating Systemd Shutdown Timer..."
echo "==================================="
local timer_file="/etc/systemd/system/day-specific-shutdown.timer"
# Calculate earliest shutdown hour (minimum of MON_WED and THU_SUN)
local earliest_hour=$SCHEDULE_MON_WED_HOUR
if [[ $SCHEDULE_THU_SUN_HOUR -lt $earliest_hour ]]; then
earliest_hour=$SCHEDULE_THU_SUN_HOUR
fi
# Generate timer entries dynamically from earliest_hour to MORNING_END_HOUR
# This ensures timer fires at all possible shutdown times
{
cat <<EOF
[Unit]
Description=Timer for automatic PC shutdown with day-specific windows
Requires=day-specific-shutdown.service
[Timer]
EOF
# Evening hours: from earliest shutdown hour to 23:30
for hour in $(seq "$earliest_hour" 23); do
printf 'OnCalendar=*-*-* %02d:00:00\n' "$hour"
printf 'OnCalendar=*-*-* %02d:30:00\n' "$hour"
done
# Morning hours: from 00:00 to MORNING_END_HOUR
for hour in $(seq 0 "$SCHEDULE_MORNING_END_HOUR"); do
printf 'OnCalendar=*-*-* %02d:00:00\n' "$hour"
if [[ $hour -lt $SCHEDULE_MORNING_END_HOUR ]]; then
printf 'OnCalendar=*-*-* %02d:30:00\n' "$hour"
fi
done
cat <<EOF
Persistent=false
AccuracySec=1s
WakeSystem=false
RandomizedDelaySec=0
[Install]
WantedBy=timers.target
EOF
} >"$timer_file"
echo "✓ Created systemd timer: $timer_file"
echo " Timer covers: ${earliest_hour}:00 to 0${SCHEDULE_MORNING_END_HOUR}:00"
}
# Function to create management script
create_management_script() {
echo ""
echo "5. Creating Management Script..."
echo "=============================="
local script_file="/usr/local/bin/day-specific-shutdown-manager.sh"
cat >"$script_file" <<'EOF'
#!/bin/bash
# Day-Specific Auto-Shutdown Manager
# Provides easy management of the day-specific shutdown feature
TIMER_NAME="day-specific-shutdown.timer"
SERVICE_NAME="day-specific-shutdown.service"
CONFIG_FILE="/etc/shutdown-schedule.conf"
# Load config for schedule display
load_config() {
if [[ -f "$CONFIG_FILE" ]]; then
# shellcheck source=/dev/null
source "$CONFIG_FILE"
else
echo "Warning: Config file $CONFIG_FILE not found"
MON_WED_HOUR="??"
THU_SUN_HOUR="??"
MORNING_END_HOUR="??"
fi
}
print_schedule() {
load_config
echo "Shutdown Schedule:"
echo " Monday-Wednesday: ${MON_WED_HOUR}:00-0${MORNING_END_HOUR}:00"
echo " Thursday-Sunday: ${THU_SUN_HOUR}:00-0${MORNING_END_HOUR}:00"
}
show_status() {
echo "Day-Specific Auto-Shutdown Status"
echo "================================="
if systemctl is-enabled "$TIMER_NAME" &>/dev/null; then
echo "Status: ENABLED"
if systemctl is-active "$TIMER_NAME" &>/dev/null; then
echo "Timer: ACTIVE"
else
echo "Timer: INACTIVE"
fi
else
echo "Status: NOT ENABLED"
fi
echo ""
print_schedule
echo ""
echo "Next scheduled checks:"
systemctl list-timers "$TIMER_NAME" --no-pager 2>/dev/null | grep "$TIMER_NAME" || echo "Timer not active"
echo ""
echo "Recent logs:"
journalctl -u "$SERVICE_NAME" --no-pager -n 5 2>/dev/null || echo "No recent logs"
}
case "$1" in
"status")
show_status
;;
"logs")
echo "Day-Specific Auto-Shutdown Logs"
echo "==============================="
journalctl -u "$SERVICE_NAME" --no-pager -n 20
;;
*)
echo "Day-Specific Auto-Shutdown Manager"
echo "Usage: $0 {status|logs}"
echo ""
echo "Commands:"
echo " status - Show current status and next shutdown checks"
echo " logs - Show recent shutdown logs"
echo ""
print_schedule
echo ""
show_status
;;
esac
EOF
chmod +x "$script_file"
echo "✓ Created management script: $script_file"
}
# Function to enable the timer
enable_timer() {
echo ""
echo "5. Enabling Shutdown Timer..."
echo "============================"
# Reload systemd daemon
systemctl daemon-reload
echo "✓ Reloaded systemd daemon"
# Enable the timer
systemctl enable day-specific-shutdown.timer
echo "✓ Enabled day-specific-shutdown timer"
# Start the timer
systemctl start day-specific-shutdown.timer
echo "✓ Started day-specific-shutdown timer"
}

View File

@ -1,132 +0,0 @@
#!/bin/bash
# Music-service detection and termination, plus the shared wait helper.
#
# Sourced by music_parallelism.sh; split out to keep it under the 250-line cap.
# Sourced rather than run, so it inherits the caller's strict mode and reads
# the interval, MUSIC_* and PROC_ROOT configuration set above the source line.
#
# find_music_services and kill_music_services stay in one file: the
# MUSIC_FOUND_PROCESS / MUSIC_FOUND_WINDOW pair the first sets and the second
# reads would otherwise cross a file boundary.
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 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
if [[ ${#found_services[@]} -gt 0 ]]; then
printf '%s\n' "${found_services[@]}"
return 0
fi
return 1
}
# Kill music services
kill_music_services() {
local use_cached_detection="${1:-0}"
local killed=false
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 [[ $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
[[ -n $wid ]] || continue
xdotool windowclose "$wid" 2>/dev/null || true
killed=true
done
fi
# 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
return 0
fi
return 1
}

View File

@ -1,91 +0,0 @@
#!/bin/bash
# The two monitoring loops: the instant (adaptive-interval) loop and the
# fixed-interval fallback loop.
#
# Sourced by music_parallelism.sh; split out to keep it under the 250-line cap.
# Sourced rather than run, so it inherits the caller's strict mode and calls
# the detection helpers in music_detect.sh, which the entry sources first.
# Send notification to user
notify_user() {
local focus_app="$1"
local message="Music stopped - focus mode active ($focus_app detected)"
# Try to send desktop notification
if command -v notify-send &>/dev/null; then
notify-send -u normal -t 5000 "🎵 Music Parallelism" "$message" 2>/dev/null || true
fi
log_message "$message"
}
# Instant monitoring loop - uses polling at high frequency ONLY when focus app is detected
# When focus app active: checks every 0.5s. When idle: checks every 3s. Reduces fork overhead.
# OPTIMIZATION: Single batched pgrep call instead of multiple separate calls
instant_monitor_loop() {
local next_enforcement_ts=0
local current_ts=0
local focus_app=""
local sleep_interval="$IDLE_CHECK_INTERVAL"
log_message "=== Music Parallelism INSTANT Monitor Started ==="
log_message "Focus apps (windows): ${FOCUS_APPS_WINDOWS[*]}"
log_message "Focus apps (processes): ${FOCUS_APPS_PROCESSES[*]}"
log_message "Polling: ${FAST_CHECK_INTERVAL}s active, ${ACTIVE_NO_MUSIC_INTERVAL}s stable-focus, ${IDLE_CHECK_INTERVAL}s idle, ${ENFORCEMENT_COOLDOWN}s enforcement cooldown"
while true; do
if focus_app=$(is_focus_app_running 2>/dev/null); then
current_ts=$(get_timestamp)
if ((current_ts >= next_enforcement_ts)); then
if find_music_services >/dev/null 2>&1; then
if kill_music_services 1; then
notify_user "$focus_app"
log_message "INSTANT KILL: Music services terminated"
sleep_interval="$ACTIVE_AFTER_KILL_INTERVAL"
fi
else
sleep_interval="$ACTIVE_NO_MUSIC_INTERVAL"
fi
next_enforcement_ts=$((current_ts + ENFORCEMENT_COOLDOWN))
else
sleep_interval="$ACTIVE_NO_MUSIC_INTERVAL"
fi
else
next_enforcement_ts=0
sleep_interval="$IDLE_CHECK_INTERVAL"
fi
wait_seconds "$sleep_interval"
done
}
# Main monitoring loop
monitor_loop() {
log_message "=== Music Parallelism Monitor Started ==="
log_message "Focus apps (windows): ${FOCUS_APPS_WINDOWS[*]}"
log_message "Focus apps (processes): ${FOCUS_APPS_PROCESSES[*]}"
log_message "Music services monitored: ${MUSIC_SERVICES[*]}"
log_message "Check interval: ${CHECK_INTERVAL}s"
while true; do
# Check if a focus app is running
local focus_app
if focus_app=$(is_focus_app_running); then
# Focus app detected, check for music services
# Named to avoid colliding with the MUSIC_SERVICES config array,
# which this file also reads.
local active_services
if active_services=$(find_music_services); then
log_message "Conflict detected: Focus app '$focus_app' running with music services"
log_message "Active music services: $active_services"
# Kill the music services
if kill_music_services 1; then
notify_user "$focus_app"
fi
fi
fi
wait_seconds "$CHECK_INTERVAL"
done
}

View File

@ -1,85 +0,0 @@
#!/bin/bash
# The status report and the usage text.
#
# Sourced by music_parallelism.sh; split out to keep it under the 250-line cap.
# Sourced rather than run, so it inherits the caller's strict mode and reads
# the interval and MUSIC_* configuration set above the source line.
# Show status
show_status() {
echo "Music Parallelism Monitor Status"
echo "================================="
echo ""
echo "Focus Applications (window-based detection):"
local focus_running=false
# Check windows (OPTIMIZED: single xdotool call with combined regex)
if command -v xdotool &>/dev/null && [[ ${#FOCUS_APPS_WINDOWS[@]} -gt 0 ]]; then
local regex
printf -v regex '%s|' "${FOCUS_APPS_WINDOWS[@]}"
regex="${regex%|}" # strip trailing |
if xdotool search --name "$regex" &>/dev/null 2>&1; then
echo " ✓ Focus window detected"
focus_running=true
fi
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
echo " (none detected)"
fi
echo ""
echo "Music Services:"
local music_running=false
if music_services=$(find_music_services 2>/dev/null); then
echo "$music_services" | while read -r svc; do
echo " ♪ $svc (RUNNING)"
done
music_running=true
fi
if ! $music_running; then
echo " (none detected)"
fi
echo ""
if $focus_running && $music_running; then
echo "⚠️ CONFLICT: Focus app and music running together!"
echo " Music would be killed in monitoring mode."
elif $focus_running; then
echo "✓ Focus mode active (no music playing)"
elif $music_running; then
echo "✓ Music playing (no focus app detected - this is fine)"
else
echo "✓ Idle (nothing detected)"
fi
}
# Show usage
show_usage() {
echo "Music Parallelism Prevention Script"
echo "===================================="
echo ""
echo "Usage: $0 [command]"
echo ""
echo "Commands:"
echo " monitor - Start monitoring (default, checks every ${CHECK_INTERVAL}s)"
echo " instant - Instant monitoring (${FAST_CHECK_INTERVAL}s active / ${IDLE_CHECK_INTERVAL}s idle)"
echo " status - Show current status of focus apps and music services"
echo " kill - Immediately kill all music services"
echo " help - Show this help message"
echo ""
echo "Description:"
echo " This script prevents multitasking between focus work and music."
echo " When a focus application (VS Code, Steam, etc.) is detected"
echo " alongside a music streaming service, the music is stopped."
echo ""
echo " Music is allowed when no focus apps are running."
echo ""
}

View File

@ -1,82 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
cmd_setup() {
set_actual_user_vars
print_setup_header "Night Lockdown"
ensure_dir "$STATE_DIR"
[[ -f "$STATE_FILE" ]] || echo UNLOCKED >"$STATE_FILE"
write_config
install_enter_script
install_unlock_script
install_unlock_units
install_rgb_off_service
install_i2c_modules
systemctl daemon-reload
enable_service night-lockdown-unlock.timer
enable_service rgb-off.service
echo ""
if verify_install; then
log_ok "Night lockdown installed."
else
log_warn "Night lockdown installed with warnings (see above)."
fi
echo ""
log_info "Next: swap the shutdown action by re-running setup_midnight_shutdown.sh"
log_info " (it must call $ENTER_SCRIPT instead of powering off)."
log_info "Emergency unlock over SSH: sudo $0 unlock"
}
cmd_status() {
local state="unknown"
[[ -r "$STATE_FILE" ]] && state="$(cat "$STATE_FILE")"
echo "Night lockdown state : $state"
echo "lightdm.service : $(systemctl is-active lightdm.service 2>/dev/null || true)"
echo "getty@.service mask : $(systemctl is-enabled getty@.service 2>/dev/null || true)"
echo "autovt@.service mask : $(systemctl is-enabled autovt@.service 2>/dev/null || true)"
echo "unlock timer : $(systemctl is-active night-lockdown-unlock.timer 2>/dev/null || true) / $(systemctl is-enabled night-lockdown-unlock.timer 2>/dev/null || true)"
echo ""
echo "Next unlock triggers:"
systemctl list-timers night-lockdown-unlock.timer --no-pager 2>/dev/null || true
}
cmd_unlock() {
log_info "Emergency unlock: restoring GUI now"
DRY_RUN="" "$UNLOCK_SCRIPT"
echo ""
log_warn "The curfew is still armed: the next 30-min check tick will re-lock."
log_warn "To stay unlocked until morning, register an override (friction by design):"
if [[ -x "$OVERRIDE_MANAGER" ]]; then
local now_h until_str
now_h="$(date '+%Y-%m-%d %H:%M')"
# Next 05:00 boundary (tomorrow if already past 05:00 today).
if [[ "$(date +%H)" -lt 5 ]]; then
until_str="$(date '+%Y-%m-%d') 05:00"
else
until_str="$(date -d tomorrow '+%Y-%m-%d') 05:00"
fi
echo " sudo $OVERRIDE_MANAGER add '$now_h' '$until_str' 'emergency unlock'"
else
log_warn " (override manager not found at $OVERRIDE_MANAGER)"
fi
}
usage() {
cat <<EOF
Night Lockdown — replace the midnight power-off with a GUI lockout that keeps
background servers running.
Usage:
sudo $0 setup Install/refresh the lock+unlock action and morning timer
$0 status Show current lockdown state and timer status
sudo $0 unlock Emergency: lift the lockdown right now (use over SSH)
$0 help Show this help
After 'setup', re-run setup_midnight_shutdown.sh so the scheduled check calls
$ENTER_SCRIPT instead of powering off.
EOF
}

View File

@ -1,125 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
# Detect ALSA card indices that expose a 'Master' control (space-separated).
detect_alsa_cards() {
local card cards=""
for card in 0 1 2 3 4 5 6; do
if amixer -c "$card" scontrols 2>/dev/null | grep -q "'Master'"; then
cards+="${card} "
fi
done
# Trim trailing space.
echo "${cards% }"
}
# Write /etc/night-lockdown.conf with values detected for THIS machine.
write_config() {
local user uid alsa_cards
user="$ACTUAL_USER"
uid="$(id -u "$user")"
alsa_cards="$(detect_alsa_cards)"
log_info "Writing config to $CONF_FILE (user=$user uid=$uid alsa_cards='$alsa_cards')"
cat >"$CONF_FILE" <<EOF
$MANAGED_BANNER
# Desktop user whose GUI session is torn down at lockdown.
LOCK_USER="$user"
LOCK_UID="$uid"
# ALSA card indices with a 'Master' control, muted at lockdown (best-effort).
ALSA_CARDS="$alsa_cards"
# Kill all RGB via OpenRGB. Detected here: 2x ENE DRAM (RGB RAM, i2c 0x71/0x73),
# ZOTAC RTX 3090 (GPU), ASRock B650M Pro RS (USB Polychrome 26ce:01a2).
RGB_ENABLE="1"
# How to turn the lighting off. Use static + black, NOT the "off" mode:
# - the ZOTAC GPU has no "Off" mode at all (its modes are Static/Breath/...);
# - the ASRock board lists "Off" but silently ignores it (mode stayed Rainbow),
# while static+black moves it to Static and goes dark.
# All four devices support Static, so this one form works everywhere. Verified:
# 'openrgb --mode static --color 000000' -> all 4 devices report ACTIVE: Static.
RGB_OFF_MODE="static"
RGB_OFF_COLOR="000000"
# HOME that openrgb runs with: it keeps its config under its own HOME, and
# systemd (HOME=/root) vs sudo (HOME=/home/USER) would otherwise disagree about
# where that config lives, so pin it for both directions.
RGB_HOME="/root"
# User systemd units (graphical-session-bound) stopped at lockdown, space-sep.
MONITORED_USER_UNITS="control-from-mobile.service"
# GUI tray processes (not systemd units) killed at lockdown, space-separated.
MONITORED_PROCS="aw-qt"
# Text console to blank at lockdown. Stopping lightdm hands the VT back to fbcon,
# which unblanks and prints kernel/systemd log spam instead of showing darkness.
CONSOLE_TTY="/dev/tty1"
# How hard to blank the console (/sys/class/graphics/fbN/blank value):
# 1 = FB_BLANK_NORMAL — screen black, monitor keeps its signal. SAFE.
# 4 = FB_BLANK_POWERDOWN — DPMS off, monitor drops to standby.
# Do NOT use 4 here: DisplayPort drops its link when powered down, and the GPU
# then sees the connector as disconnected and will not re-train it on wake —
# DP-0 vanished from xrandr and needed the cable physically re-plugged. HDMI
# tolerates it, DP does not. 1 still gives a fully black screen, which is the
# whole point, without touching the link.
CONSOLE_BLANK_MODE="1"
# Never start the morning alarm earlier than lockdown-entry + this many hours,
# even though the unlock timer itself still fires on its normal staggered
# schedule (05:00/05:15/05:30/06:00/07:00). A late lockdown entry (e.g. 23:00
# instead of the usual 21:00) must not still wake you 6h later.
MIN_WAKE_AFTER_LOCKDOWN_HOURS="8"
# autorandr profile to restore after the GUI comes back up. Create it once with
# 'autorandr --save default' while all monitors are connected and arranged the
# way you want; the unlock script re-applies it so a DisplayPort link that
# didn't re-enumerate cleanly doesn't require a manual xrandr/replug.
AUTORANDR_PROFILE="default"
# Cap CPU/GPU power draw for the night as a safety net against anything left
# running headless overnight (interactive GPU/CPU load dies with the GUI
# teardown above, but a systemd service — e.g. ollama serving a request — does
# not). Set to "0" to disable without touching the enter/unlock scripts.
POWER_SAVE_ENABLE="1"
# AMD amd-pstate-epp hint applied to every core at lockdown (this machine is
# Ryzen, not Intel, so intel_pstate/no_turbo does not apply). One of: default,
# performance, balance_performance, balance_power, power, custom.
CPU_EPP_NIGHT="power"
# nvidia-smi power limit (watts) applied at lockdown. Set to this card's
# reported power.min_limit (100W on the RTX 3090 here) so a runaway overnight
# CUDA job is bounded, not eliminated — persistence mode is deliberately left
# alone so any legitimate headless GPU service keeps working, just slower.
GPU_POWER_LIMIT_NIGHT_WATTS="100"
# Fully unbind the GPU from the PCI bus at lockdown (unload nvidia kernel
# modules + PCI remove), rescanned back at unlock. Far more aggressive than
# the power-limit cap above: recovers more idle wattage, but modprobe -r can
# hang if anything still references the device, and a botched unbind might
# need a hard power-cycle to recover -- which WOULD take every container down
# with it (the one thing night-lockdown exists to prevent). Default OFF and
# deliberately left off: the first real run of this is an uncorroborated test
# on this exact kernel/driver, not a dress rehearsal -- nothing can safely
# test it without also tearing down the GUI it runs under.
GPU_UNBIND_ENABLE="0"
EOF
chmod 0644 "$CONF_FILE"
# This heredoc is intentionally UNQUOTED so the detected values above
# interpolate — which also means a stray backtick or $ anywhere in it
# (even inside a comment) gets executed/expanded and its output injected
# into the file. That has bitten twice, and a corrupted config makes the
# lock script fail to source it and silently skip steps. Fail loudly here
# instead of shipping a broken config.
if ! bash -n "$CONF_FILE" 2>/dev/null; then
log_error "Generated $CONF_FILE is not valid shell — refusing to continue."
log_error "Check write_config() for backticks or \$ in the heredoc."
bash -n "$CONF_FILE" || true
exit 1
fi
}

View File

@ -1,19 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
# Where the verbatim payload files live. Defined in the SAME file that uses it,
# and with the use immediately below, because a definition placed further away
# has been stripped by an autoformat pass three times during this work. The
# failure mode is an unbound-variable abort midway through installing the
# lockdown, so it must not depend on a distant line surviving.
: "${_NL_PAYLOAD_DIR:=$(cd "$(dirname "${BASH_SOURCE[0]}")/payloads" && pwd)}"
install_enter_script() {
log_info "Installing lock action to $ENTER_SCRIPT"
# The payload is a DATA FILE, not a heredoc: inlining it put this lib over
# the 250-line cap, and the body is a complete standalone script that is
# easier to read, lint and diff on its own. The heredoc was quoted
# (<<'ENTER_EOF'), so the body was already literal — copying it verbatim is
# exactly equivalent. Verified by hashing the emitted result.
install -m 0755 "$_NL_PAYLOAD_DIR/night-lockdown-enter.sh.in" "$ENTER_SCRIPT"
}

View File

@ -1,100 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
install_unlock_units() {
log_info "Installing morning-unlock timer family"
cat >"$UNLOCK_SERVICE" <<EOF
$MANAGED_BANNER
[Unit]
Description=Lift night lockdown (restore GUI, unmute, unmask login surface)
DefaultDependencies=false
After=multi-user.target
[Service]
Type=oneshot
ExecStart=$UNLOCK_SCRIPT
StandardOutput=journal
StandardError=journal
EOF
# Multiple staggered triggers + Persistent=true = dead-man robustness: a
# single missed/failed 05:00 run cannot strand the machine, and each run is
# idempotent so repeated firing is harmless.
cat >"$UNLOCK_TIMER" <<EOF
$MANAGED_BANNER
[Unit]
Description=Morning triggers to lift night lockdown
[Timer]
OnCalendar=*-*-* 05:00:00
OnCalendar=*-*-* 05:15:00
OnCalendar=*-*-* 05:30:00
OnCalendar=*-*-* 06:00:00
OnCalendar=*-*-* 07:00:00
Persistent=true
AccuracySec=1s
[Install]
WantedBy=timers.target
EOF
}
# Lights stay off at ALL times, not just during lockdown. The board firmware
# re-lights RAM/board/GPU on every power-on, so without this the RGB would be
# back after each reboot and only go dark again at the next 21:00 lock.
install_rgb_off_service() {
log_info "Installing boot-time RGB-off service"
cat >"$RGB_OFF_SERVICE" <<EOF
$MANAGED_BANNER
[Unit]
Description=Turn all RGB lighting off (lights are meant to be off at all times)
After=multi-user.target
# The i2c DRAM/GPU controllers need their buses; the ASRock controller is USB.
Wants=systemd-modules-load.service
After=systemd-modules-load.service
[Service]
Type=oneshot
RemainAfterExit=yes
# HOME pinned for the same reason as in the lock/unlock scripts: openrgb keeps
# its state under HOME and systemd would otherwise differ from an interactive
# sudo run. Detection takes ~2s before it applies. static+black rather than
# "off": the ZOTAC GPU has no Off mode and the ASRock board ignores it.
ExecStart=/usr/bin/env HOME=${RGB_HOME_DEFAULT} /usr/bin/openrgb --mode static --color 000000
SuccessExitStatus=0 1
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOF
}
verify_install() {
log_info "Verifying installation"
local ok=true
local path
for path in "$ENTER_SCRIPT" "$UNLOCK_SCRIPT" "$CONF_FILE" \
"$UNLOCK_SERVICE" "$UNLOCK_TIMER" "$RGB_OFF_SERVICE" "$I2C_MODULES_FILE"; do
if [[ -e "$path" ]]; then
log_ok "present: $path"
else
log_error "missing: $path"
ok=false
fi
done
if is_service_enabled night-lockdown-unlock.timer; then
log_ok "night-lockdown-unlock.timer is enabled"
else
log_error "night-lockdown-unlock.timer is NOT enabled"
ok=false
fi
# The action swap in the fortress check script is what actually invokes us.
if grep -q "$ENTER_SCRIPT" /usr/local/bin/day-specific-shutdown-check.sh 2>/dev/null; then
log_ok "day-specific-shutdown-check.sh calls the lockdown action"
else
log_warn "day-specific-shutdown-check.sh does NOT call $ENTER_SCRIPT yet"
log_warn " → re-run setup_midnight_shutdown.sh to regenerate it (see header)"
fi
[[ "$ok" == true ]]
}

View File

@ -1,19 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
# Where the verbatim payload files live. Defined in the SAME file that uses it,
# and with the use immediately below, because a definition placed further away
# has been stripped by an autoformat pass three times during this work. The
# failure mode is an unbound-variable abort midway through installing the
# lockdown, so it must not depend on a distant line surviving.
: "${_NL_PAYLOAD_DIR:=$(cd "$(dirname "${BASH_SOURCE[0]}")/payloads" && pwd)}"
install_unlock_script() {
log_info "Installing reversal to $UNLOCK_SCRIPT"
# The payload is a DATA FILE, not a heredoc: inlining it put this lib over
# the 250-line cap, and the body is a complete standalone script that is
# easier to read, lint and diff on its own. The heredoc was quoted
# (<<'UNLOCK_EOF'), so the body was already literal — copying it verbatim is
# exactly equivalent. Verified by hashing the emitted result.
install -m 0755 "$_NL_PAYLOAD_DIR/night-lockdown-unlock.sh.in" "$UNLOCK_SCRIPT"
}

View File

@ -1,279 +0,0 @@
#!/bin/bash
# Managed by setup_night_lockdown.sh — do not edit by hand.
#
# Enter night lockdown: tear down the user GUI and mask the TTY login surface so
# the machine is unusable from the keyboard, while every background server keeps
# running. Reversed by night-lockdown-unlock.sh (05:00 timer or SSH recovery).
# All steps are best-effort and logged; DRY_RUN=1 logs intended actions only.
set -euo pipefail
readonly CONF_FILE="/etc/night-lockdown.conf"
readonly STATE_DIR="/var/lib/night-lockdown"
readonly STATE_FILE="$STATE_DIR/state"
# shellcheck source=/dev/null
[[ -r "$CONF_FILE" ]] && source "$CONF_FILE"
LOCK_USER="${LOCK_USER:-}"
LOCK_UID="${LOCK_UID:-}"
ALSA_CARDS="${ALSA_CARDS:-}"
RGB_ENABLE="${RGB_ENABLE:-1}"
MONITORED_USER_UNITS="${MONITORED_USER_UNITS:-}"
MONITORED_PROCS="${MONITORED_PROCS:-}"
MIN_WAKE_AFTER_LOCKDOWN_HOURS="${MIN_WAKE_AFTER_LOCKDOWN_HOURS:-8}"
CONSOLE_TTY="${CONSOLE_TTY:-/dev/tty1}"
CONSOLE_BLANK_MODE="${CONSOLE_BLANK_MODE:-1}"
POWER_SAVE_ENABLE="${POWER_SAVE_ENABLE:-1}"
CPU_EPP_NIGHT="${CPU_EPP_NIGHT:-power}"
GPU_POWER_LIMIT_NIGHT_WATTS="${GPU_POWER_LIMIT_NIGHT_WATTS:-100}"
GPU_UNBIND_ENABLE="${GPU_UNBIND_ENABLE:-0}"
DRY_RUN="${DRY_RUN:-}"
logg() { logger -t night-lockdown "$*"; printf 'night-lockdown: %s\n' "$*"; }
if [[ "$DRY_RUN" == "1" ]]; then
logg "DRY_RUN NOTE: log-only — lightdm is never actually stopped and the"
logg "DRY_RUN NOTE: console framebuffer is never actually touched. This CANNOT"
logg "DRY_RUN NOTE: exercise the blank-vs-VT-switch timing race (bug: visible"
logg "DRY_RUN NOTE: console text). A clean DRY_RUN run is NOT proof that fix"
logg "DRY_RUN NOTE: works — only a real run (DRY_RUN unset) validates it."
logg "DRY_RUN NOTE: The CPU EPP write, nvidia-smi power-limit cap, and (if"
logg "DRY_RUN NOTE: enabled) GPU PCI-unbind are also never actually applied"
logg "DRY_RUN NOTE: under DRY_RUN — real-hardware timing/driver behavior for"
logg "DRY_RUN NOTE: those is unverified until a real run."
fi
# Run a command best-effort: log-only under DRY_RUN, never abort the lockdown.
run() {
if [[ "$DRY_RUN" == "1" ]]; then
logg "DRY_RUN: would run: $*"
return 0
fi
if ! "$@"; then
logg "WARN: command failed (continuing): $*"
fi
}
# Run a command as the desktop user inside their session bus.
run_as_user() {
[[ -n "$LOCK_USER" && -n "$LOCK_UID" && -d "/run/user/$LOCK_UID" ]] || return 0
run sudo -u "$LOCK_USER" env "XDG_RUNTIME_DIR=/run/user/$LOCK_UID" "$@"
}
# Silence everything that prints to the text console. Once lightdm stops, the VT
# reverts to fbcon and any kernel/systemd message lights the screen back up.
silence_console() {
# systemd status messages ("Starting/Stopped ...") -> off. RTMIN+20 re-enables.
run kill -s RTMIN+21 1
# Kernel messages -> console. Save the current printk so unlock can restore it.
if [[ "$DRY_RUN" != "1" ]]; then
cat /proc/sys/kernel/printk >"$STATE_DIR/printk.prev" 2>/dev/null || true
fi
run sysctl -q -w "kernel.printk=1 4 1 4"
}
# Force the console dark — black screen, but WITHOUT DPMS power management.
# Powering the output down (FB_BLANK_POWERDOWN / setterm --powersave powerdown)
# makes DisplayPort drop its link; the GPU then reports the connector
# disconnected and never re-trains it, so the monitor is gone from xrandr until
# the cable is physically re-plugged. Blanking alone is enough to go black.
blank_console() {
local fb
for fb in /sys/class/graphics/fb*/blank; do
[[ -e "$fb" ]] || continue
if [[ "$DRY_RUN" == "1" ]]; then
logg "DRY_RUN: would write $CONSOLE_BLANK_MODE (blank) > $fb"
continue
fi
echo "$CONSOLE_BLANK_MODE" >"$fb" 2>/dev/null || logg "WARN: could not blank $fb"
done
# setterm emits escape sequences on stdout; from a systemd service stdout is
# the journal, so aim it at the VT explicitly. --powersave off keeps DPMS out
# of it so the DP link survives.
if [[ "$DRY_RUN" == "1" ]]; then
logg "DRY_RUN: would force console blank (no DPMS) on $CONSOLE_TTY"
elif [[ -w "$CONSOLE_TTY" ]]; then
setterm --term linux --blank force --powersave off \
>"$CONSOLE_TTY" 2>/dev/null || logg "WARN: setterm blank failed"
fi
}
mkdir -p "$STATE_DIR"
# 1. Idempotency — entering lockdown when already locked is a safe no-op.
if [[ "$(cat "$STATE_FILE" 2>/dev/null || echo UNLOCKED)" == "LOCKED" ]]; then
logg "already LOCKED — nothing to do"
exit 0
fi
logg "entering night lockdown"
# 2. RGB off (RAM + board + GPU), remembering the current lighting first.
#
# Do NOT gate this on `openrgb --list-devices`: that call is broken in the
# installed openrgb-git build — it returns in ~70ms, before async detection
# finishes, so it always reports zero devices. (That false "no controllers"
# reading is why this step silently no-op'd.) The fix was to install stable
# openrgb 1.0rc3 from extra, whose CLI detects properly (~1.9s) before acting.
if [[ "$RGB_ENABLE" == "1" ]] && command -v openrgb >/dev/null 2>&1; then
run env HOME="$RGB_HOME" openrgb --mode "$RGB_OFF_MODE" --color "$RGB_OFF_COLOR"
fi
# 2b. Cap CPU/GPU power draw for the night. Save the pre-lockdown values first
# so unlock restores exactly what was there, not a hardcoded "default" —
# the user may have changed either away from factory settings.
# Capture is guarded on the .prev file NOT already existing: this step
# runs before state is written LOCKED (step 5), so a kill between here and
# there leaves state UNLOCKED and the 30-min curfew check re-invokes this
# whole script. Without the guard, that re-run would recapture "prev" from
# the already-applied NIGHT value, permanently stranding the machine in
# night power-save mode. unlock deletes the .prev file after a successful
# restore, so a normal (non-crashed) cycle still captures fresh each night.
if [[ "$POWER_SAVE_ENABLE" == "1" ]]; then
if [[ -f /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference ]]; then
if [[ "$DRY_RUN" != "1" && ! -f "$STATE_DIR/cpu_epp.prev" ]]; then
cat /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference \
>"$STATE_DIR/cpu_epp.prev" 2>/dev/null || true
fi
for epp_file in /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference; do
[[ -e "$epp_file" ]] || continue
if [[ "$DRY_RUN" == "1" ]]; then
logg "DRY_RUN: would write $CPU_EPP_NIGHT > $epp_file"
else
echo "$CPU_EPP_NIGHT" >"$epp_file" 2>/dev/null || logg "WARN: could not set EPP on $epp_file"
fi
done
fi
if command -v nvidia-smi >/dev/null 2>&1; then
if [[ "$DRY_RUN" != "1" && ! -f "$STATE_DIR/gpu_power_limit_watts.prev" ]]; then
nvidia-smi --query-gpu=power.limit --format=csv,noheader,nounits \
>"$STATE_DIR/gpu_power_limit_watts.prev" 2>/dev/null || true
fi
if [[ "$DRY_RUN" == "1" ]]; then
logg "DRY_RUN: would run: timeout 10 nvidia-smi -pl $GPU_POWER_LIMIT_NIGHT_WATTS"
else
run timeout 10 nvidia-smi -pl "$GPU_POWER_LIMIT_NIGHT_WATTS"
fi
fi
fi
# 3. Mute audio (best-effort). The session teardown below silences apps anyway;
# this covers any direct-ALSA sound. PipeWire is intentionally NOT touched, so
# the morning unlock cannot be defeated by a persisted wireplumber mute.
for card in $ALSA_CARDS; do
command -v amixer >/dev/null 2>&1 && run amixer -c "$card" -q set Master mute
done
# 4. Stop GUI-bound user units + tray apps (they die with X anyway; be tidy).
for unit in $MONITORED_USER_UNITS; do
run_as_user systemctl --user stop "$unit"
done
for proc in $MONITORED_PROCS; do
# Only kill if actually running — pkill exits 1 on no-match, which would log
# a misleading "command failed" WARN every night for an app that isn't up.
if [[ -n "$LOCK_USER" ]] && pgrep -u "$LOCK_USER" -x "$proc" >/dev/null 2>&1; then
run pkill -u "$LOCK_USER" -x "$proc"
fi
done
# 5. Persist the LOCKED intent BEFORE the session-killing teardown. Stopping
# lightdm tears down the graphical session that this very process runs in, so
# if we wrote state AFTER the teardown, a kill mid-teardown would leave state
# at UNLOCKED — and the unlock path (which no-ops when UNLOCKED) would then
# fail to restore, stranding the machine. Write intent first; any unlock
# (05:00 timer, dead-man, or manual) then correctly restores.
if [[ "$DRY_RUN" != "1" ]]; then
echo LOCKED >"$STATE_FILE"
lockdown_epoch="$(date +%s)"
echo "$lockdown_epoch" >"$STATE_DIR/locked_at_epoch"
# 5a. Schedule a one-shot wake-floor trigger for exactly
# lockdown_epoch + MIN_WAKE_AFTER_LOCKDOWN_HOURS. The staggered
# 05:00/05:15/05:30/06:00/07:00 unlock triggers are a fixed list sized
# for the usual shutdown hours (~21:00-23:00), but the shutdown hour
# itself is configurable (/etc/shutdown-schedule.conf) and changes
# dynamically (see the sick-day feature). If lockdown ever starts late
# enough that the floor lands after every remaining fixed trigger, the
# alarm would otherwise silently never fire that morning. This
# transient timer fires at the exact floor moment for THIS lockdown
# regardless of what time it actually started, re-running the
# (idempotent, self-dedup'd) unlock script, which is where the alarm
# is actually attempted.
floor_epoch=$((lockdown_epoch + MIN_WAKE_AFTER_LOCKDOWN_HOURS * 3600))
floor_cal="$(date -d "@$floor_epoch" '+%Y-%m-%d %H:%M:%S')"
run systemd-run --unit=night-lockdown-wake-floor --collect \
--on-calendar="$floor_cal" /usr/local/bin/night-lockdown-unlock.sh
else
logg "DRY_RUN: would schedule a one-shot wake-floor trigger ~${MIN_WAKE_AFTER_LOCKDOWN_HOURS}h from now"
fi
# 6a. Silence the console BEFORE stopping the GUI, so the teardown itself
# ("Stopping Light Display Manager...") does not print onto the VT.
silence_console
# 6. Mask the login surface BEFORE stopping the GUI, so logind cannot spawn a
# getty into the gap. Mask ONLY getty@.service: autovt@.service is an alias
# symlink to it, so masking getty@ already makes autovt@ttyN resolve to
# masked ("Unit autovt@ttyN.service is masked" — logind cannot spawn a VT
# login). Passing autovt@ to mask instead FAILS ("File
# /etc/systemd/system/autovt@.service already exists and is a symlink"),
# which is what produced the nightly WARN. Masks are /dev/null symlinks that
# persist across reboot; the unlock path unmasks them.
run systemctl mask getty@.service
run systemctl stop 'getty@*.service'
# 7. Stop the GUI while continuously re-blanking the console in the
# background. Activating a VT (which happens somewhere mid-way through the
# GUI stop, not only once "systemctl stop" fully returns) makes fbcon
# redraw/unblank as part of the switch, so a single blank-after-the-fact
# call leaves a gap showing whatever was last on the console (old
# kernel/systemd text) for however long the rest of the stop sequence
# takes. Keep forcing it dark for the whole teardown instead of waiting.
if [[ "$DRY_RUN" != "1" ]]; then
(
while :; do
blank_console
sleep 0.1
done
) &
BLANK_POLLER_PID=$!
fi
run systemctl stop lightdm.service
if [[ -n "${BLANK_POLLER_PID:-}" ]]; then
kill "$BLANK_POLLER_PID" 2>/dev/null || true
wait "$BLANK_POLLER_PID" 2>/dev/null || true
unset BLANK_POLLER_PID
fi
# 8. Final guaranteed blank now that the GUI is fully down.
blank_console
# 9. Optionally unbind the GPU entirely (default OFF — see GPU_UNBIND_ENABLE
# comment in night-lockdown.conf for the risk tradeoff). Only ever runs
# after the GUI is torn down, since nothing interactive needs the GPU at
# this point. Timeout-guarded: modprobe -r can hang if anything still
# references the device, and a hang here must not block the rest of
# lockdown.
if [[ "$GPU_UNBIND_ENABLE" == "1" ]] && command -v nvidia-smi >/dev/null 2>&1; then
gpu_pci="$(lspci -d 10de: 2>/dev/null | grep -i 'VGA\|3D' | awk '{print $1}' | head -1)"
if [[ -z "$gpu_pci" ]]; then
logg "WARN: GPU_UNBIND_ENABLE=1 but no nvidia PCI device found"
elif [[ "$DRY_RUN" == "1" ]]; then
logg "DRY_RUN: would unload nvidia modules and remove PCI device 0000:$gpu_pci"
elif timeout 20 modprobe -r nvidia_uvm nvidia_drm nvidia_modeset nvidia 2>&1 | logger -t night-lockdown; then
if echo 1 >"/sys/bus/pci/devices/0000:$gpu_pci/remove" 2>/dev/null; then
logg "GPU unbound (0000:$gpu_pci removed from PCI bus)"
# Unloading nvidia_drm / the PCI remove can surface a fallback
# framebuffer that isn't blanked -- same failure class the
# continuous re-blank during the lightdm stop (above) exists to
# close, just triggered by module unload instead of a VT switch.
blank_console
else
logg "WARN: modules unloaded but PCI remove failed for 0000:$gpu_pci"
fi
else
logg "WARN: modprobe -r timed out or failed -- GPU left bound, skipping unbind"
fi
fi
logg "night lockdown active — servers up, GUI down, login surface masked, console dark"

View File

@ -1,212 +0,0 @@
#!/bin/bash
# Managed by setup_night_lockdown.sh — do not edit by hand.
#
# Lift night lockdown: unmask the login surface, restart the GUI, unmute audio.
# Idempotent (safe to fire from the 5 staggered morning triggers and manual runs)
# and deliberately independent of the shutdown fortress, so it can always run.
set -euo pipefail
readonly CONF_FILE="/etc/night-lockdown.conf"
readonly STATE_DIR="/var/lib/night-lockdown"
readonly STATE_FILE="$STATE_DIR/state"
# shellcheck source=/dev/null
[[ -r "$CONF_FILE" ]] && source "$CONF_FILE"
ALSA_CARDS="${ALSA_CARDS:-}"
LOCK_USER="${LOCK_USER:-}"
LOCK_UID="${LOCK_UID:-}"
MIN_WAKE_AFTER_LOCKDOWN_HOURS="${MIN_WAKE_AFTER_LOCKDOWN_HOURS:-8}"
AUTORANDR_PROFILE="${AUTORANDR_PROFILE:-default}"
CONSOLE_TTY="${CONSOLE_TTY:-/dev/tty1}"
RGB_ENABLE="${RGB_ENABLE:-1}"
RGB_OFF_MODE="${RGB_OFF_MODE:-static}"
RGB_OFF_COLOR="${RGB_OFF_COLOR:-000000}"
RGB_HOME="${RGB_HOME:-/root}"
POWER_SAVE_ENABLE="${POWER_SAVE_ENABLE:-1}"
GPU_UNBIND_ENABLE="${GPU_UNBIND_ENABLE:-0}"
DRY_RUN="${DRY_RUN:-}"
logg() { logger -t night-lockdown "$*"; printf 'night-lockdown: %s\n' "$*"; }
if [[ "$DRY_RUN" == "1" ]]; then
logg "DRY_RUN NOTE: log-only — lightdm is never actually started and"
logg "DRY_RUN NOTE: autorandr is never actually invoked. This CANNOT exercise"
logg "DRY_RUN NOTE: the real DisplayPort reconnect or graphical-session wait."
logg "DRY_RUN NOTE: A clean DRY_RUN run is NOT proof those work — only a real"
logg "DRY_RUN NOTE: run (DRY_RUN unset) validates them. The alarm floor/dedup"
logg "DRY_RUN NOTE: logic (locked_at_epoch) IS exercised"
logg "DRY_RUN NOTE: faithfully here, since that part is pure calculation."
logg "DRY_RUN NOTE: The CPU EPP / GPU power-limit restore and (if enabled)"
logg "DRY_RUN NOTE: the PCI rescan are also never actually applied under"
logg "DRY_RUN NOTE: DRY_RUN — real-hardware timing/driver behavior for"
logg "DRY_RUN NOTE: those is unverified until a real run."
fi
run() {
if [[ "$DRY_RUN" == "1" ]]; then
logg "DRY_RUN: would run: $*"
return 0
fi
if ! "$@"; then
logg "WARN: command failed (continuing): $*"
fi
}
# NOTE: this script used to also start morning-routine.service here, on a
# fixed 05:00/05:15/05:30/06:00/07:00 ladder. wake-alarm-trigger.timer now
# owns firing the alarm: it ticks every minute and fires at the time the
# phone synced, so the alarm follows the phone instead of a hardcoded hour.
# Keeping both meant a 07:00 alarm on a morning the phone said 08:00.
mkdir -p "$STATE_DIR"
# Idempotency — lifting when already unlocked is a safe no-op.
if [[ "$(cat "$STATE_FILE" 2>/dev/null || echo UNLOCKED)" == "UNLOCKED" ]]; then
logg "already UNLOCKED — nothing else to do"
exit 0
fi
logg "lifting night lockdown"
# 0. Restore the console: unblank it and let kernel/systemd talk to it again.
# Must happen even if everything below fails, or the machine would stay
# permanently silent on the console after any unlock.
restore_console() {
local fb prev
for fb in /sys/class/graphics/fb*/blank; do
[[ -e "$fb" ]] || continue
if [[ "$DRY_RUN" == "1" ]]; then
logg "DRY_RUN: would write 0 (unblank) > $fb"
continue
fi
echo 0 >"$fb" 2>/dev/null || true
done
if [[ "$DRY_RUN" == "1" ]]; then
logg "DRY_RUN: would restore printk + systemd status messages"
return 0
fi
[[ -w "$CONSOLE_TTY" ]] && setterm --term linux --blank poke --powersave off \
>"$CONSOLE_TTY" 2>/dev/null
if [[ -r "$STATE_DIR/printk.prev" ]]; then
prev="$(cat "$STATE_DIR/printk.prev")"
[[ -n "$prev" ]] && sysctl -q -w "kernel.printk=$prev" 2>/dev/null
fi
kill -s RTMIN+20 1 2>/dev/null || true # systemd: status messages back on
}
restore_console
# 1. Unmask the login surface (undo the persistent /dev/null mask). Only
# getty@.service is ever masked; autovt@.service is just an alias to it.
run systemctl unmask getty@.service
# 1a. If the GPU was unbound at lockdown, rescan the PCI bus to bring it back
# before starting the GUI. Harmless no-op if nothing was removed. Polls
# for the driver actually being ready (nvidia-smi succeeding) rather than
# a fixed sleep -- starting lightdm against a GPU that hasn't finished
# reprobing risks a failed X start, which (unlike a mis-enumerated
# display) the autorandr retry loop above cannot recover from.
if [[ "$GPU_UNBIND_ENABLE" == "1" ]]; then
if [[ "$DRY_RUN" == "1" ]]; then
logg "DRY_RUN: would rescan PCI bus and wait for the GPU driver to be ready"
else
echo 1 >/sys/bus/pci/rescan 2>/dev/null || logg "WARN: PCI rescan failed"
gpu_ready=false
for ((i = 0; i < 15; i++)); do
if nvidia-smi >/dev/null 2>&1; then
gpu_ready=true
break
fi
sleep 1
done
if [[ "$gpu_ready" == true ]]; then
logg "GPU driver ready after PCI rescan"
else
logg "WARN: GPU driver not ready after rescan + 15s -- starting GUI anyway"
fi
fi
fi
# 2. Start the GUI — lightdm autologins back into i3/dwm.
run systemctl start lightdm.service
# 2a. Reconnect/reconfigure displays. A DisplayPort link does not always
# re-enumerate cleanly on its own after the console teardown, leaving a
# monitor disconnected until someone runs xrandr by hand. Retry autorandr
# for a bit while the session comes up instead. Requires a saved profile
# ('autorandr --save default' run once while all monitors are connected).
if [[ -n "$LOCK_USER" && -n "$LOCK_UID" ]] && command -v autorandr >/dev/null 2>&1; then
if [[ "$DRY_RUN" == "1" ]]; then
logg "DRY_RUN: would run autorandr --change as $LOCK_USER"
else
autorandr_ok=false
for ((i = 0; i < 15; i++)); do
if sudo -u "$LOCK_USER" env "XDG_RUNTIME_DIR=/run/user/$LOCK_UID" DISPLAY=:0 \
autorandr --change --default "$AUTORANDR_PROFILE" >/dev/null 2>&1; then
autorandr_ok=true
break
fi
sleep 1
done
if [[ "$autorandr_ok" == true ]]; then
logg "autorandr: displays restored (profile '$AUTORANDR_PROFILE')"
else
logg "WARN: autorandr did not settle after retries — displays may need manual xrandr"
fi
fi
fi
# 3. Restore audio (unmute the cards muted at lockdown).
for card in $ALSA_CARDS; do
command -v amixer >/dev/null 2>&1 && run amixer -c "$card" -q set Master unmute
done
# 3b. Restore CPU EPP and GPU power limit to their pre-lockdown values (saved
# by night-lockdown-enter.sh). Missing state file = enter never ran the
# power-save step (e.g. it was disabled then) — nothing to restore.
# Delete each .prev file after a successful (non-DRY_RUN) restore: enter's
# capture is guarded on the file NOT existing (crash-safety, see its own
# comment), so leaving a stale file here would freeze future captures to
# tonight's value forever instead of tracking whatever the user's daytime
# setting actually is.
if [[ "$POWER_SAVE_ENABLE" == "1" ]]; then
if [[ -r "$STATE_DIR/cpu_epp.prev" ]]; then
prev_epp="$(cat "$STATE_DIR/cpu_epp.prev")"
if [[ -n "$prev_epp" ]]; then
for epp_file in /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference; do
[[ -e "$epp_file" ]] || continue
if [[ "$DRY_RUN" == "1" ]]; then
logg "DRY_RUN: would write $prev_epp > $epp_file"
else
echo "$prev_epp" >"$epp_file" 2>/dev/null || logg "WARN: could not restore EPP on $epp_file"
fi
done
fi
[[ "$DRY_RUN" == "1" ]] || rm -f "$STATE_DIR/cpu_epp.prev"
fi
if command -v nvidia-smi >/dev/null 2>&1 && [[ -r "$STATE_DIR/gpu_power_limit_watts.prev" ]]; then
prev_watts="$(cat "$STATE_DIR/gpu_power_limit_watts.prev")"
if [[ -n "$prev_watts" ]]; then
if [[ "$DRY_RUN" == "1" ]]; then
logg "DRY_RUN: would run: timeout 10 nvidia-smi -pl $prev_watts"
else
run timeout 10 nvidia-smi -pl "$prev_watts"
fi
fi
[[ "$DRY_RUN" == "1" ]] || rm -f "$STATE_DIR/gpu_power_limit_watts.prev"
fi
fi
# 4. Re-assert lights-off rather than restoring anything: the preference is no
# lighting at all, ever, so the morning must not turn the RGB back on.
if [[ "$RGB_ENABLE" == "1" ]] && command -v openrgb >/dev/null 2>&1; then
run env HOME="$RGB_HOME" openrgb --mode "$RGB_OFF_MODE" --color "$RGB_OFF_COLOR"
fi
# 5. Record state BEFORE the (possibly long) alarm wait, so re-fired unlock
# triggers dedupe on the state token instead of double-starting things.
if [[ "$DRY_RUN" != "1" ]]; then
echo UNLOCKED >"$STATE_FILE"
fi
logg "night lockdown lifted — GUI restored, login surface unmasked"

View File

@ -1,224 +0,0 @@
#!/bin/bash
# Unlock shutdown schedule config for editing with smart friction
# This script:
# - NO delay if making schedule STRICTER (earlier shutdown hours)
# - DELAY if making schedule more LENIENT (later shutdown hours)
# - BLOCKS lowering MORNING_END_HOUR (that would shorten the shutdown window)
set -euo pipefail
DELAY_SECONDS=45
GUARD_NAME="shutdown-schedule"
CONFIG_FILE="/etc/shutdown-schedule.conf"
LOG_FILE="/var/log/shutdown-schedule-guard.log"
EDITOR="${EDITOR:-nano}"
TEMP_FILE="/tmp/shutdown-schedule-edit.$$"
log() {
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" >&2
}
# Must be root
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root (sudo)"
exit 1
fi
CANONICAL_FILE="$(guardctl file-guard canonical-path "$GUARD_NAME")"
if [[ -z "$CANONICAL_FILE" ]]; then
echo "Error: guard-lib instance '$GUARD_NAME' is not installed (guardctl file-guard canonical-path returned empty)" >&2
exit 1
fi
# Log the unlock attempt
log "=== UNLOCK ATTEMPT by $(logname 2>/dev/null || echo 'unknown') from TTY $(tty 2>/dev/null || echo 'unknown') ==="
# Load current values
OLD_MON_WED=""
OLD_THU_SUN=""
OLD_MORNING_END=""
if [[ -f "$CANONICAL_FILE" ]]; then
# shellcheck source=/dev/null
source "$CANONICAL_FILE" 2>/dev/null || true
OLD_MON_WED="${MON_WED_HOUR:-}"
OLD_THU_SUN="${THU_SUN_HOUR:-}"
OLD_MORNING_END="${MORNING_END_HOUR:-}"
fi
echo ""
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║ SHUTDOWN SCHEDULE CONFIG UNLOCK ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
echo ""
echo "Current schedule:"
echo " Monday-Wednesday: ${OLD_MON_WED:-??}:00 - 0${OLD_MORNING_END:-?}:00"
echo " Thursday-Sunday: ${OLD_THU_SUN:-??}:00 - 0${OLD_MORNING_END:-?}:00"
echo ""
echo "Rules:"
echo " ✓ Making shutdown EARLIER (stricter) = No delay"
echo " ⏳ Making shutdown LATER (lenient) = ${DELAY_SECONDS}s delay required"
echo " ❌ Lowering MORNING_END_HOUR = BLOCKED (would shorten shutdown window)"
echo ""
# Stop the path watcher temporarily. This is NOT optional: `chattr -i`
# below is itself enough to fire guard-file@shutdown-schedule.path (its
# PathModified reacts to attribute changes, not just content writes), and
# that watcher's enforce pass unconditionally re-locks the target at the
# end even when no drift is found - which would silently re-lock the file
# out from under us during the 45s delay below. Confirmed live: without
# this stop, the delayed-apply cp failed with "Operation not permitted".
systemctl stop "guard-file@${GUARD_NAME}.path" 2>/dev/null || true
# Remove immutable attributes
chattr -i -a "$CONFIG_FILE" 2>/dev/null || true
chattr -i -a "$CANONICAL_FILE" 2>/dev/null || true
# Copy config to temp file for editing
cp "$CONFIG_FILE" "$TEMP_FILE"
echo "Opening editor..."
echo ""
# Open editor on temp file
$EDITOR "$TEMP_FILE"
# Load new values from edited file
NEW_MON_WED=""
NEW_THU_SUN=""
NEW_MORNING_END=""
# shellcheck source=/dev/null
source "$TEMP_FILE" 2>/dev/null || true
NEW_MON_WED="${MON_WED_HOUR:-}"
NEW_THU_SUN="${THU_SUN_HOUR:-}"
NEW_MORNING_END="${MORNING_END_HOUR:-}"
echo ""
echo "Checking changes..."
# Check for blocked changes (lowering MORNING_END_HOUR)
if [[ -n "$OLD_MORNING_END" ]] && [[ -n "$NEW_MORNING_END" ]]; then
if [[ "$NEW_MORNING_END" -lt "$OLD_MORNING_END" ]]; then
echo ""
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║ ❌ CHANGE BLOCKED - CANNOT LOWER MORNING_END_HOUR ❌ ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
echo ""
echo "You tried to lower MORNING_END_HOUR from $OLD_MORNING_END to $NEW_MORNING_END"
echo "This would SHORTEN the shutdown window, making it more lenient."
echo ""
echo "This change is NOT allowed. The shutdown window must end at"
echo "0${OLD_MORNING_END}:00 or later."
echo ""
rm -f "$TEMP_FILE"
# Re-apply protection
chattr +i "$CONFIG_FILE" 2>/dev/null || true
chattr +i "$CANONICAL_FILE" 2>/dev/null || true
systemctl start "guard-file@${GUARD_NAME}.path" 2>/dev/null || true
log "BLOCKED: User tried to lower MORNING_END_HOUR from $OLD_MORNING_END to $NEW_MORNING_END"
exit 1
fi
fi
# Check if changes require delay (making schedule more lenient)
NEEDS_DELAY=false
LENIENT_CHANGES=()
if [[ -n "$OLD_MON_WED" ]] && [[ -n "$NEW_MON_WED" ]]; then
if [[ "$NEW_MON_WED" -gt "$OLD_MON_WED" ]]; then
NEEDS_DELAY=true
LENIENT_CHANGES+=("Mon-Wed: ${OLD_MON_WED}:00 → ${NEW_MON_WED}:00 (later)")
fi
fi
if [[ -n "$OLD_THU_SUN" ]] && [[ -n "$NEW_THU_SUN" ]]; then
if [[ "$NEW_THU_SUN" -gt "$OLD_THU_SUN" ]]; then
NEEDS_DELAY=true
LENIENT_CHANGES+=("Thu-Sun: ${OLD_THU_SUN}:00 → ${NEW_THU_SUN}:00 (later)")
fi
fi
# Check for stricter changes (allowed without delay)
STRICTER_CHANGES=()
if [[ -n "$OLD_MON_WED" ]] && [[ -n "$NEW_MON_WED" ]]; then
if [[ "$NEW_MON_WED" -lt "$OLD_MON_WED" ]]; then
STRICTER_CHANGES+=("Mon-Wed: ${OLD_MON_WED}:00 → ${NEW_MON_WED}:00 (earlier)")
fi
fi
if [[ -n "$OLD_THU_SUN" ]] && [[ -n "$NEW_THU_SUN" ]]; then
if [[ "$NEW_THU_SUN" -lt "$OLD_THU_SUN" ]]; then
STRICTER_CHANGES+=("Thu-Sun: ${OLD_THU_SUN}:00 → ${NEW_THU_SUN}:00 (earlier)")
fi
fi
if [[ -n "$OLD_MORNING_END" ]] && [[ -n "$NEW_MORNING_END" ]]; then
if [[ "$NEW_MORNING_END" -gt "$OLD_MORNING_END" ]]; then
STRICTER_CHANGES+=("Morning end: 0${OLD_MORNING_END}:00 → 0${NEW_MORNING_END}:00 (later = longer window)")
fi
fi
# Report stricter changes
if [[ ${#STRICTER_CHANGES[@]} -gt 0 ]]; then
echo ""
echo "✓ Stricter changes detected (no delay needed):"
for s in "${STRICTER_CHANGES[@]}"; do
echo " • $s"
done
fi
# Handle lenient changes
if [[ "$NEEDS_DELAY" == true ]]; then
echo ""
echo "⚠️ More lenient changes detected:"
for l in "${LENIENT_CHANGES[@]}"; do
echo " • $l"
done
echo ""
echo "Are you making this change for a good reason, or are you just"
echo "trying to stay up later? Remember why you set these limits."
echo ""
echo "To proceed, you must wait $DELAY_SECONDS seconds..."
echo ""
# Countdown with opportunity to cancel
for ((i=DELAY_SECONDS; i>0; i--)); do
printf "\r ⏳ Waiting: %2d seconds remaining... (Ctrl+C to cancel)" "$i"
sleep 1
done
echo ""
echo ""
log "User waited through delay for lenient changes: ${LENIENT_CHANGES[*]}"
else
echo ""
echo "✓ No delay required (schedule is same or stricter)"
fi
# Apply the changes
cp "$TEMP_FILE" "$CONFIG_FILE"
cp "$TEMP_FILE" "$CANONICAL_FILE"
rm -f "$TEMP_FILE"
chmod 644 "$CONFIG_FILE"
chmod 644 "$CANONICAL_FILE"
# Re-apply immutable
chattr +i "$CONFIG_FILE" || echo "Warning: Could not set immutable attribute"
chattr +i "$CANONICAL_FILE" || echo "Warning: Could not set immutable attribute"
# Restart path watcher (stopped near the start of this script)
systemctl start "guard-file@${GUARD_NAME}.path" 2>/dev/null || true
log "Config updated and re-locked by user"
echo ""
echo "✓ Config file updated and re-protected"
echo "✓ Canonical copy updated"
echo "✓ Path watcher re-enabled"
echo ""
echo "New schedule (will take effect on next timer check):"
# shellcheck source=/dev/null
source "$CONFIG_FILE" 2>/dev/null || true
echo " Monday-Wednesday: ${MON_WED_HOUR:-??}:00 - 0${MORNING_END_HOUR:-?}:00"
echo " Thursday-Sunday: ${THU_SUN_HOUR:-??}:00 - 0${MORNING_END_HOUR:-?}:00"
echo ""

View File

@ -1,146 +0,0 @@
#!/usr/bin/env bash
# lib/tests/cco_harness.sh — shared fixture for the block_compulsive_opening.sh split.
#
# Sourced, not executed. Scope: the hour-key/state logic that decides whether an
# app launch is allowed. Everything that moves system binaries (install_wrapper,
# uninstall_wrapper, install_all) or kills processes (kill_app, launch_with_timer)
# is NOT executed here — this machine's beeper/signal/discord wrappers and a live
# pacman hook depend on those paths, and a test that ran them would rewrite them.
#
# STATE_DIR and LOG_FILE were already variables in the pre-split script, so the
# tests point them at a tmpdir and nothing touches ~/.local/state.
set -euo pipefail
PASS=0
FAIL=0
_t_pass() {
PASS=$((PASS + 1))
printf ' OK: %s\n' "$1"
}
_t_fail() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$1"
}
_t_eq() {
local want="$1"
local got="$2"
local what="$3"
if [[ $got == "$want" ]]; then
_t_pass "$what"
else
_t_fail "$what (want '${want}', got '${got}')"
fi
}
_t_has() {
local haystack="$1"
local needle="$2"
local what="$3"
if [[ $haystack == *"$needle"* ]]; then
_t_pass "$what"
else
_t_fail "$what (want a substring '${needle}')"
fi
}
# These helpers return 0/1 to mean allowed/blocked, and which way they fail is
# the whole point — assert on the status explicitly rather than letting `set -e`
# end the run. Called via `if` so a non-zero return does not abort the suite.
_t_true() {
local what="$2"
if ("$1"); then
_t_pass "$what"
else
_t_fail "$what (expected success, got non-zero)"
fi
}
_t_false() {
local what="$2"
if ("$1"); then
_t_fail "$what (expected non-zero, got success)"
else
_t_pass "$what"
fi
}
CCO_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export CCO_LIB_DIR
# Stand in for the globals the entry script defines before sourcing the libs.
_t_cco_setup() {
TEST_TMPDIR="$(mktemp -d)"
export TEST_TMPDIR
STATE_DIR="$TEST_TMPDIR/state"
LOG_FILE="$STATE_DIR/compulsive-block.log"
mkdir -p "$STATE_DIR"
AUTO_CLOSE_TIMEOUT_MINUTES=10
AUTO_CLOSE_WARNING_MINUTES=2
declare -gA APP_TIMEOUT_MINUTES=(["beeper"]=20 ["signal-desktop"]=20)
declare -gA APPS=(
["beeper"]="$TEST_TMPDIR/bin/beeper"
["signal-desktop"]="$TEST_TMPDIR/bin/signal-desktop"
["discord"]="$TEST_TMPDIR/bin/discord"
)
declare -gA REAL_BINARIES=(
["beeper"]="$TEST_TMPDIR/real/beepertexts"
["signal-desktop"]="$TEST_TMPDIR/real/signal-desktop"
["discord"]="$TEST_TMPDIR/bin/discord.orig"
)
declare -gA PROCESS_MATCH=(["discord"]="$TEST_TMPDIR/home/.config/discord/")
mkdir -p "$TEST_TMPDIR/bin" "$TEST_TMPDIR/real"
# notify() is defined in the entry script, which the tests never source.
notify() { printf 'NOTIFY: %s | %s\n' "$1" "$2" >>"$TEST_TMPDIR/notifications"; }
: >"$TEST_TMPDIR/notifications"
_t_cco_assert_fixture_complete
}
# Every global above is read by the sourced lib, never by this file, so the
# linter reports each one as unused (SC2034) and notify() as uncalled
# (SC2329) — it cannot see across the `source` boundary the tests set up.
# Referencing them here is not linter appeasement: it is a real fixture check.
# A typo in one of these names would otherwise surface as a confusing unbound
# variable from deep inside the code under test, or worse, as a silently
# skipped assertion.
_t_cco_assert_fixture_complete() {
local missing=""
[[ -n $LOG_FILE ]] || missing+=" LOG_FILE"
[[ -n $AUTO_CLOSE_TIMEOUT_MINUTES ]] || missing+=" AUTO_CLOSE_TIMEOUT_MINUTES"
[[ -n $AUTO_CLOSE_WARNING_MINUTES ]] || missing+=" AUTO_CLOSE_WARNING_MINUTES"
[[ ${#APPS[@]} -eq 3 ]] || missing+=" APPS"
[[ ${#REAL_BINARIES[@]} -eq 3 ]] || missing+=" REAL_BINARIES"
[[ ${#APP_TIMEOUT_MINUTES[@]} -eq 2 ]] || missing+=" APP_TIMEOUT_MINUTES"
[[ ${#PROCESS_MATCH[@]} -eq 1 ]] || missing+=" PROCESS_MATCH"
# Call notify() for real rather than probing it with `type -t`: block_app
# routes every blocked launch through it, so a fixture whose notify is
# broken would turn the notification assertions into silent no-ops. The
# probe file is truncated afterwards so this call is not mistaken for one
# made by the code under test.
notify "fixture" "self-check"
[[ -s "$TEST_TMPDIR/notifications" ]] || missing+=" notify"
: >"$TEST_TMPDIR/notifications"
if [[ -n $missing ]]; then
printf 'harness fixture incomplete:%s\n' "$missing" >&2
exit 1
fi
}
_t_cco_teardown() {
[[ -n ${TEST_TMPDIR:-} && -d ${TEST_TMPDIR} ]] && rm -rf "$TEST_TMPDIR"
}
_t_report() {
printf '\n%s: %d passed, %d failed\n' "${1:-tests}" "$PASS" "$FAIL"
[[ $FAIL -eq 0 ]]
}

View File

@ -1,150 +0,0 @@
#!/usr/bin/env bash
# lib/tests/leechblock_harness.sh — shared fixture for the install_leechblock.sh split.
#
# Sourced, not executed. Scope is deliberately narrow: the phases that download
# archives, pkill browsers, patch system binaries under sudo and write
# /etc/*/policies.json are never executed here — running them would rewrite
# this machine's browsers. Those phases are covered by the textual
# wrap-verification recorded in the evidence instead.
#
# What IS executed: the pure logic that decides *what* to do — tag resolution
# across its three curl fallbacks, version normalisation, and browser-table
# construction. curl/jq are shimmed on PATH so no network call is made.
set -euo pipefail
PASS=0
FAIL=0
_t_pass() {
PASS=$((PASS + 1))
printf ' OK: %s\n' "$1"
}
_t_fail() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$1"
}
_t_eq() {
local want="$1"
local got="$2"
local what="$3"
if [[ $got == "$want" ]]; then
_t_pass "$what"
else
_t_fail "$what (want '${want}', got '${got}')"
fi
}
_t_has() {
local haystack="$1"
local needle="$2"
local what="$3"
if [[ $haystack == *"$needle"* ]]; then
_t_pass "$what"
else
_t_fail "$what (want a substring '${needle}')"
fi
}
# A function that calls `exit` would kill the test script if invoked directly
# in an `if` condition, so failures are asserted inside a subshell.
_t_exits_nonzero() {
local what="$2"
if ("$1" >/dev/null 2>&1); then
_t_fail "$what (expected a non-zero exit, got 0)"
else
_t_pass "$what"
fi
}
LEECHBLOCK_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export LEECHBLOCK_LIB_DIR
# The libs expect these logging helpers from the entry script.
info() { printf '[INFO] %s\n' "$*"; }
warn() { printf '[WARN] %s\n' "$*"; }
err() { printf '[ERR ] %s\n' "$*"; }
# Build a throwaway PATH front so `curl`, `jq` and friends are controllable.
# Every shim writes what it was asked for into $TEST_TMPDIR so assertions can
# inspect the call, and reads its canned reply from a per-test file.
_t_setup_shims() {
TEST_TMPDIR="$(mktemp -d)"
SHIM_DIR="$TEST_TMPDIR/bin"
mkdir -p "$SHIM_DIR"
cat >"$SHIM_DIR/curl" <<'SHIM'
#!/usr/bin/env bash
# Records argv, then replays a canned body chosen by the caller.
printf '%s\n' "$*" >>"$TEST_TMPDIR/curl.calls"
if [[ -n ${CURL_FAIL_ALL:-} ]]; then
exit 22
fi
if [[ $* == *"-fsSLI"* ]]; then
[[ -f "$TEST_TMPDIR/curl.head" ]] && cat "$TEST_TMPDIR/curl.head"
exit 0
fi
if [[ $* == *"/releases/latest"* ]]; then
[[ -f "$TEST_TMPDIR/curl.releases" ]] && cat "$TEST_TMPDIR/curl.releases"
exit 0
fi
if [[ $* == *"/tags"* ]]; then
[[ -f "$TEST_TMPDIR/curl.tags" ]] && cat "$TEST_TMPDIR/curl.tags"
exit 0
fi
exit 0
SHIM
chmod +x "$SHIM_DIR/curl"
# _t_hide_jq narrows PATH to SHIM_DIR alone, so every external command still
# needed on that path must be reachable from here: awk/tr for the
# redirect-header fallback, env+bash because the curl shim's own shebang is
# `#!/usr/bin/env bash`, and rm/cat/mktemp for the harness itself.
# Without these the fallback would fail for the wrong reason and the test
# would be asserting nothing.
# mkdir is load-bearing: detect_browsers creates the desktop-entry dir, and
# a missing mkdir aborts the whole suite under `set -e` with no stderr at
# all — which under kcov looks like a coverage-tool problem rather than a
# PATH problem. Keep this list in sync with the externals the libs call.
local tool
for tool in awk tr cat mkdir mktemp rm env bash sed grep wc readlink; do
ln -sf "$(command -v "$tool")" "$SHIM_DIR/$tool"
done
export TEST_TMPDIR
PATH="$SHIM_DIR:$PATH"
export PATH
}
# Narrow PATH to the shim dir alone. Two tests need this and neither can be
# written any other way:
# * hiding jq — shadowing it with a non-executable shim is NOT enough,
# because `command -v` keeps searching PATH and finds the real jq;
# * hiding browsers — this machine genuinely has Chromium and Firefox
# installed, so "no browser detected" is otherwise untestable.
# _t_setup_shims pre-seeds the shim dir with the utilities the code under test
# calls, so narrowing hides only what the test means to hide.
_t_isolate_path() {
_T_SAVED_PATH="$PATH"
PATH="$SHIM_DIR"
export PATH
}
_t_restore_path() {
PATH="$_T_SAVED_PATH"
export PATH
}
# Readable aliases for the jq-specific use of the same mechanism.
_t_hide_jq() { _t_isolate_path; }
_t_show_jq() { _t_restore_path; }
_t_teardown() {
[[ -n ${TEST_TMPDIR:-} && -d ${TEST_TMPDIR} ]] && rm -rf "$TEST_TMPDIR"
}
_t_report() {
printf '\n%s: %d passed, %d failed\n' "${1:-tests}" "$PASS" "$FAIL"
[[ $FAIL -eq 0 ]]
}

View File

@ -1,23 +0,0 @@
#!/usr/bin/env bash
# Runs every digital_wellbeing lib test. Used directly as the coverage subject:
# meta/scripts/shell_coverage.sh wants the test script itself, not `bash <it>`.
#
# The glob is test_*.sh, NOT one prefix per split: this directory holds suites
# for several entry scripts (leechblock, block_compulsive_opening, ...), and a
# prefix glob silently skips every suite added after it — including in the CI
# discovery step, which runs this file.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
rc=0
for t in "$HERE"/test_*.sh; do
printf '\n=== %s ===\n' "$(basename "$t")"
# Each suite exits non-zero on failure; keep going so one red suite does not
# hide the state of the others, then fail the run as a whole.
# Invoked directly, NOT as `bash "$t"`: under kcov the latter instruments
# bash itself and reports zero lines for every lib.
"$t" || rc=1
done
exit "$rc"

View File

@ -1,151 +0,0 @@
#!/usr/bin/env bash
# Covers lib/cco_state.sh — the logic that decides whether a launch is allowed.
#
# This is the whole point of the tool: was_opened_this_hour deciding wrong in
# either direction is the failure that matters. Too permissive and the blocker
# does nothing; too strict and a legitimately-closed app can never be reopened.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=cco_harness.sh
. "$HERE/cco_harness.sh"
# shellcheck source=../cco_state.sh
. "$CCO_LIB_DIR/cco_state.sh"
_t_cco_setup
trap _t_cco_teardown EXIT
printf 'get_hour_key: a stable YYYY-MM-DD-HH stamp\n'
hour="$(get_hour_key)"
if [[ $hour =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{2}$ ]]; then
_t_pass "the hour key is YYYY-MM-DD-HH"
else
_t_fail "the hour key is YYYY-MM-DD-HH (got '$hour')"
fi
_t_eq "$hour" "$(get_hour_key)" "two calls in the same hour agree"
printf '\nget_state_file: one file per app, under STATE_DIR\n'
_t_eq "$STATE_DIR/discord.lastopen" "$(get_state_file discord)" "the state path is derived from the app name"
_t_eq "$STATE_DIR/discord.running" "$(get_running_file discord)" "the running-state path is separate from the lastopen path"
printf '\nwas_opened_this_hour: the core allow/block decision\n'
rm -f "$STATE_DIR/discord.lastopen"
if was_opened_this_hour discord; then
_t_fail "a fresh app is not yet opened this hour"
else
_t_pass "a fresh app is not yet opened this hour"
fi
record_opening discord >/dev/null 2>&1
if was_opened_this_hour discord; then
_t_pass "after record_opening the app counts as opened"
else
_t_fail "after record_opening the app counts as opened"
fi
printf '\nwas_opened_this_hour: a stale hour must NOT keep blocking\n'
# The bug that would matter most: if a previous hour's stamp still blocked, an
# app could never be reopened. Write a deliberately old stamp.
echo "2000-01-01-00" >"$STATE_DIR/discord.lastopen"
if was_opened_this_hour discord; then
_t_fail "a previous hour's stamp must not block the current hour"
else
_t_pass "a previous hour's stamp does not block the current hour"
fi
printf '\nwas_opened_this_hour: an unreadable/empty stamp allows the launch\n'
: >"$STATE_DIR/discord.lastopen"
if was_opened_this_hour discord; then
_t_fail "an empty state file must not block"
else
_t_pass "an empty state file does not block"
fi
printf '\nrecord_opening: writes exactly the current hour\n'
rm -f "$STATE_DIR/beeper.lastopen"
record_opening beeper >/dev/null 2>&1
_t_eq "$(get_hour_key)" "$(cat "$STATE_DIR/beeper.lastopen")" "the stamp written is the current hour key"
printf '\nblock_app: notifies the user\n'
: >"$TEST_TMPDIR/notifications"
block_app discord >/dev/null 2>&1
_t_has "$(cat "$TEST_TMPDIR/notifications")" "Blocked" "blocking sends a desktop notification"
printf '\nis_autoclose_suspended: only todays marker counts\n'
rm -f "$STATE_DIR/discord.suspend-autoclose"
if is_autoclose_suspended discord; then
_t_fail "no marker means not suspended"
else
_t_pass "no marker means not suspended"
fi
printf '%(%Y-%m-%d)T' -1 >"$STATE_DIR/discord.suspend-autoclose"
if is_autoclose_suspended discord; then
_t_pass "todays marker suspends auto-close"
else
_t_fail "todays marker suspends auto-close"
fi
echo "1999-12-31" >"$STATE_DIR/discord.suspend-autoclose"
if is_autoclose_suspended discord; then
_t_fail "a stale marker must not suspend auto-close"
else
_t_pass "a stale marker does not suspend auto-close"
fi
if [[ -f "$STATE_DIR/discord.suspend-autoclose" ]]; then
_t_fail "a stale marker is cleaned up on detection"
else
_t_pass "a stale marker is cleaned up on detection"
fi
printf '\nget_real_binary: only reports a binary once the wrapper is installed\n'
rm -f "$TEST_TMPDIR/bin/beeper.orig"
if get_real_binary beeper >/dev/null 2>&1; then
_t_fail "an unwrapped app reports no real binary"
else
_t_pass "an unwrapped app reports no real binary"
fi
: >"$TEST_TMPDIR/bin/beeper.orig"
_t_eq "$TEST_TMPDIR/real/beepertexts" "$(get_real_binary beeper)" "a wrapped app resolves to its real binary"
printf '\nlog_message: appends to the log and echoes to stderr\n'
: >"$LOG_FILE"
log_message "hello from the test" 2>/dev/null
_t_has "$(cat "$LOG_FILE")" "hello from the test" "the message reaches the log file"
printf '\nensure_state_dir: creates STATE_DIR when it is missing\n'
rm -rf "$STATE_DIR"
ensure_state_dir
if [[ -d $STATE_DIR ]]; then
_t_pass "a missing state directory is created"
else
_t_fail "a missing state directory is created"
fi
# Idempotent: the real script calls it on every wrapper launch.
ensure_state_dir
_t_pass "calling it again on an existing directory is harmless"
printf '\ncleanup_stale_running_state: clears state when nothing is running\n'
running="$(get_running_file discord)"
echo "999999 123" >"$running"
# PROCESS_MATCH points at a path no process can match, so this is the
# "app already exited" case.
cleanup_stale_running_state discord 2>/dev/null
if [[ -f $running ]]; then
_t_fail "a running-state file for a dead process is removed"
else
_t_pass "a running-state file for a dead process is removed"
fi
printf '\ncleanup_stale_running_state: no running file is a no-op\n'
# The early return. It must not fall through to the pgrep path, which on a
# machine where the app IS running would otherwise be consulted for nothing.
rm -f "$running"
if (cleanup_stale_running_state discord); then
_t_pass "a missing running-state file returns success without probing"
else
_t_fail "a missing running-state file returns success without probing"
fi
_t_report "test_cco_state"

View File

@ -1,85 +0,0 @@
#!/usr/bin/env bash
# Covers lib/leechblock_browsers.sh's detection table and dispatch logic.
#
# replace_browser_in_place is NOT executed: it pkills browsers and rewrites
# system binaries under sudo. What is asserted here is everything that decides
# *whether* it runs and *what* it would be handed — the table itself, the
# global-scope contract, and the command-v gating in wire_up_browsers.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=leechblock_harness.sh
source "$HERE/leechblock_harness.sh"
# shellcheck source=../leechblock_browsers.sh
source "$LEECHBLOCK_LIB_DIR/leechblock_browsers.sh"
_t_setup_shims
trap _t_teardown EXIT
HOME="$TEST_TMPDIR/home"
XDG_DATA_HOME="$TEST_TMPDIR/home/.local/share"
# Assigned by wire_up_browsers in the sourced lib. Declared here so the
# assertions below read as checking a known variable rather than tripping
# SC2154 on something shellcheck cannot trace across the source boundary.
ff_found=0
printf 'detect_browsers: the tables survive as globals\n'
# declare -A inside a function is function-local; the pre-split code ran at top
# level. If the -g were ever dropped these counts would be 0 and every browser
# would silently go unwired, so assert the count rather than just a lookup.
detect_browsers
_t_eq "8" "${#BROWSERS[@]}" "all eight Chromium-family browsers are present"
_t_eq "3" "${#FIREFOXES[@]}" "all three Firefox-family browsers are present"
printf '\ndetect_browsers: hyphenated keys resolve\n'
# The subscripts are QUOTED deliberately. shfmt -w rewrites an unquoted
# ${BROWSERS[google-chrome-stable]} into arithmetic
# (the key becomes a subtraction expression), which resolves to the empty key and
# makes every one of these assertions read an absent entry. Reproduced in this
# very file during the split; quoting is what makes shfmt leave it alone.
_t_eq "Google Chrome" "${BROWSERS["google-chrome-stable"]}" "google-chrome-stable maps to its pretty name"
_t_eq "Brave" "${BROWSERS["brave-browser"]}" "brave-browser maps to its pretty name"
_t_eq "Vivaldi" "${BROWSERS["vivaldi-stable"]}" "vivaldi-stable maps to its pretty name"
_t_eq "Thorium" "${BROWSERS["thorium-browser"]}" "thorium-browser maps to its pretty name"
_t_eq "Firefox Developer Edition" "${FIREFOXES["firefox-developer-edition"]}" "firefox-developer-edition maps to its pretty name"
printf '\ndetect_browsers: it creates the user applications directory\n'
if [[ -d "$XDG_DATA_HOME/applications" ]]; then
_t_pass "the desktop-entry directory is created"
else
_t_fail "the desktop-entry directory is missing"
fi
_t_eq "0" "$found_any" "found_any starts at zero"
printf '\nwire_up_browsers: no installed browser means nothing is patched\n'
# This machine really does have Chromium and Firefox installed, so PATH must be
# narrowed to the shim dir for "nothing installed" to be testable at all.
# _t_setup_shims seeded that dir with the utilities the function needs, so
# narrowing hides the browsers without breaking the code under test.
_t_isolate_path
replace_browser_in_place() {
printf 'PATCHED %s\n' "$1" >>"$TEST_TMPDIR/patched"
found_any=1
}
: >"$TEST_TMPDIR/patched"
wire_up_browsers >"$TEST_TMPDIR/wire.out" 2>&1
_t_restore_path
_t_eq "0" "$(wc -l <"$TEST_TMPDIR/patched")" "no browser present means no binary is touched"
_t_eq "0" "$ff_found" "no Firefox present leaves ff_found at zero"
_t_has "$(cat "$TEST_TMPDIR/wire.out")" "No supported browsers detected" "it reports that nothing was found"
printf '\nwire_up_browsers: an installed browser is dispatched with its pretty name\n'
# SHIM_DIR is first on PATH, so these stand in for a real installed browser.
printf '#!/usr/bin/env bash\nexit 0\n' >"$SHIM_DIR/chromium"
chmod +x "$SHIM_DIR/chromium"
printf '#!/usr/bin/env bash\nexit 0\n' >"$SHIM_DIR/firefox"
chmod +x "$SHIM_DIR/firefox"
: >"$TEST_TMPDIR/patched"
found_any=0
wire_up_browsers >"$TEST_TMPDIR/wire2.out" 2>&1
_t_has "$(cat "$TEST_TMPDIR/patched")" "PATCHED chromium" "an installed Chromium is handed to the patcher"
_t_eq "1" "$ff_found" "an installed Firefox sets ff_found"
_t_has "$(cat "$TEST_TMPDIR/wire2.out")" "Detected Firefox-based browser" "it prints the Firefox guidance"
_t_report "test_leechblock_browsers"

View File

@ -1,88 +0,0 @@
#!/usr/bin/env bash
# Covers lib/leechblock_fetch.sh's tag resolution and version normalisation.
#
# get_latest_tag has three fallbacks in priority order (releases API → tags
# API → redirect-header parse). A silent failure there installs the WRONG
# version of a blocking extension, so each fallback is exercised separately,
# including the path where jq is absent entirely.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=leechblock_harness.sh
source "$HERE/leechblock_harness.sh"
# shellcheck source=../leechblock_fetch.sh
source "$LEECHBLOCK_LIB_DIR/leechblock_fetch.sh"
# Exported because get_latest_tag reads them from the environment of the
# sourced lib rather than taking them as arguments; a plain assignment reads as
# unused here (SC2034) even though the code under test depends on it.
export REPO_OWNER="proginosko"
export REPO_NAME="LeechBlockNG-chrome"
_t_setup_shims
trap _t_teardown EXIT
printf 'get_latest_tag: releases API wins when it answers\n'
printf '{"tag_name":"v1.2.3"}\n' >"$TEST_TMPDIR/curl.releases"
# A subshell would strand nothing here (the value is echoed), but the call is
# routed through a file so a future assertion on side effects still works.
get_latest_tag "$REPO_NAME" >"$TEST_TMPDIR/out"
_t_eq "v1.2.3" "$(cat "$TEST_TMPDIR/out")" "the releases endpoint supplies the tag"
_t_has "$(cat "$TEST_TMPDIR/curl.calls")" "releases/latest" "it queried the releases endpoint"
printf '\nget_latest_tag: falls back to the tags API when releases is empty\n'
: >"$TEST_TMPDIR/curl.calls"
printf '{}\n' >"$TEST_TMPDIR/curl.releases"
printf '[{"name":"v9.9.9"}]\n' >"$TEST_TMPDIR/curl.tags"
get_latest_tag "$REPO_NAME" >"$TEST_TMPDIR/out"
_t_eq "v9.9.9" "$(cat "$TEST_TMPDIR/out")" "an empty releases reply falls through to tags"
_t_has "$(cat "$TEST_TMPDIR/curl.calls")" "tags?per_page=1" "it queried the tags endpoint"
printf '\nget_latest_tag: a literal null is treated as absent, not as a tag\n'
printf '{"tag_name":null}\n' >"$TEST_TMPDIR/curl.releases"
printf '[{"name":"v4.5.6"}]\n' >"$TEST_TMPDIR/curl.tags"
get_latest_tag "$REPO_NAME" >"$TEST_TMPDIR/out"
_t_eq "v4.5.6" "$(cat "$TEST_TMPDIR/out")" "a JSON null does not become the version"
printf '\nget_latest_tag: redirect-header fallback when jq is unavailable\n'
_t_hide_jq
printf 'location: https://github.com/x/y/releases/tag/v7.7.7\r\n' >"$TEST_TMPDIR/curl.head"
get_latest_tag "$REPO_NAME" >"$TEST_TMPDIR/out"
_t_eq "v7.7.7" "$(cat "$TEST_TMPDIR/out")" "the Location header supplies the tag without jq"
printf '\nget_latest_tag: reports failure when every source is exhausted\n'
# Still inside the hidden-jq PATH: with no Location header either, all three
# fallbacks are exhausted. The caller aborts the install on this non-zero
# status, so returning 0 with an empty tag would install a bogus version.
: >"$TEST_TMPDIR/curl.head"
if get_latest_tag "$REPO_NAME" >"$TEST_TMPDIR/out" 2>&1; then
_t_fail "an exhausted lookup must return non-zero"
else
_t_pass "an exhausted lookup returns non-zero rather than an empty version"
fi
_t_show_jq
printf '\nresolve_version: normalises the tag and derives the install layout\n'
VERSION="v2.0.1"
HOME="$TEST_TMPDIR/home"
XDG_DATA_HOME="$TEST_TMPDIR/home/.local/share"
resolve_version
_t_eq "2.0.1" "$VERSION" "the leading v is stripped for directory names"
_t_eq "v2.0.1" "$TAG" "the tag keeps its leading v"
_t_eq "$XDG_DATA_HOME/leechblockng/2.0.1" "$VERSION_DIR" "VERSION_DIR is derived from the stripped version"
_t_eq "$XDG_DATA_HOME/leechblockng/current" "$CURRENT_LINK" "CURRENT_LINK is the stable wrapper path"
printf '\nresolve_version: a non-standard tag warns but still installs\n'
VERSION="nightly-build"
# warn() prints to stdout, not stderr, so redirect stdout to capture it.
resolve_version >"$TEST_TMPDIR/warn" 2>&1
_t_has "$(cat "$TEST_TMPDIR/warn")" "doesn't look like" "an odd tag produces a warning"
_t_eq "nightly-build" "$VERSION" "an odd tag is still honoured rather than rejected"
printf '\nresolve_version: an unset VERSION resolves from GitHub\n'
printf '{"tag_name":"v3.3.3"}\n' >"$TEST_TMPDIR/curl.releases"
VERSION=""
resolve_version >/dev/null
_t_eq "3.3.3" "$VERSION" "an empty VERSION is filled from the releases endpoint"
_t_report "test_leechblock_fetch"

View File

@ -1,96 +0,0 @@
# Systemd units, check-script logic and common tasks
## Systemd Units
### Timer (fires every minute)
```ini
[Timer]
OnCalendar=*:*:00
Persistent=false
AccuracySec=1s
```
### Check Service
```ini
[Service]
Type=oneshot
ExecStart=/usr/local/bin/day-specific-shutdown-check.sh
```
### Path Watcher
```ini
[Path]
PathChanged=/etc/shutdown-schedule.conf
Unit=shutdown-schedule-guard.service
```
## Check Script Logic
```bash
# Pseudocode for day-specific-shutdown-check.sh
source /etc/shutdown-schedule.conf
day=$(date +%u) # 1=Monday, 7=Sunday
hour=$(date +%H)
if [[ $day -le 3 ]]; then
shutdown_hour=$MON_WED_HOUR
else
shutdown_hour=$THU_SUN_HOUR
fi
# Check if in shutdown window
if [[ $hour -ge $shutdown_hour ]] || [[ $hour -lt $MORNING_END_HOUR ]]; then
systemctl poweroff
fi
```
## Common Tasks
### Check Current Status
```bash
/usr/local/bin/day-specific-shutdown-manager.sh status
# Or run setup script with 'status' argument
```
### Make Schedule Stricter
Edit the constants in `setup_midnight_shutdown.sh`:
```bash
SCHEDULE_MON_WED_HOUR=20 # Changed from 21 to 20 (earlier)
```
Then re-run:
```bash
sudo ./setup_midnight_shutdown.sh
```
### Make Schedule More Lenient (Requires Unlock)
```bash
sudo /usr/local/sbin/unlock-shutdown-schedule
# Wait for delay, edit config, save
```
### Disable Timer (Will Be Re-Enabled!)
```bash
sudo systemctl disable --now day-specific-shutdown.timer
# Monitor service will re-enable it automatically
```
### Check Protection Status
```bash
lsattr /etc/shutdown-schedule.conf
# Should show: ----i--------e--
systemctl status shutdown-schedule-guard.path
systemctl status shutdown-timer-monitor.service
```

View File

@ -1,51 +0,0 @@
# Known vulnerabilities, troubleshooting and hard stops
## KNOWN VULNERABILITIES
1. **Information Disclosure**: Error messages tell user exactly how to bypass
2. **Unlock Script Discoverable**: Path mentioned in error messages
3. **Timer Monitor Killable**: User can stop the monitor then the timer
4. **Check Script Unprotected**: `/usr/local/bin/day-specific-shutdown-check.sh` can be edited
**TODO**:
- Remove helpful bypass instructions from error messages
- Rename unlock script to obscure name
- Protect check script with integrity verification
## Troubleshooting
### Timer not firing
```bash
systemctl status day-specific-shutdown.timer
systemctl list-timers | grep shutdown
```
### Config not being enforced
```bash
# Check path watcher
systemctl status shutdown-schedule-guard.path
# Manually trigger enforcement
sudo /usr/local/sbin/enforce-shutdown-schedule.sh
```
### Wrong time shown in i3blocks
```bash
# Verify config
cat /etc/shutdown-schedule.conf
# Check i3blocks config
cat ~/.config/i3blocks/config | grep shutdown
```
## DO NOT
1. ❌ Edit setup script constants to make schedule later (will be blocked)
2. ❌ Delete canonical config (breaks restoration)
3. ❌ Stop `shutdown-timer-monitor.service` (timer will be re-enabled anyway)
4. ❌ Modify check script to skip shutdown (defeats purpose)
5. ❌ Lower `MORNING_END_HOUR` (always blocked, shortens shutdown window)

View File

@ -1,135 +0,0 @@
#!/bin/bash
# Music Parallelism Prevention Script
# Prevents listening to music while doing focus work (coding, gaming)
#
# When a focus application (VS Code, Steam games, etc.) is detected alongside
# a music streaming service (YouTube Music, Spotify, etc.), the music is stopped.
#
# Music is fine when running alone - only killed when combined with focus apps.
set -euo pipefail
# Source common library for shared functions
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
if [[ -f "$SCRIPT_DIR/../../lib/common.sh" ]]; then
# shellcheck source=../../lib/common.sh
source "$SCRIPT_DIR/../../lib/common.sh"
elif [[ -f "/usr/local/lib/common.sh" ]]; then
# shellcheck source=/usr/local/lib/common.sh
# Deployed to /usr/local/lib at install time; no in-repo copy exists at that
# absolute path for the linter to follow.
# shellcheck disable=SC1091
source "/usr/local/lib/common.sh"
else
echo "ERROR: common.sh library not found"
exit 1
fi
# Configuration
LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/music-parallelism"
mkdir -p "$LOG_DIR" 2>/dev/null || true
export LOG_FILE="$LOG_DIR/music-parallelism.log"
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=(
# IDEs and code editors - match window titles
"Visual Studio Code"
"VSCodium"
"Cursor"
"IntelliJ IDEA"
"PyCharm"
"WebStorm"
"CLion"
"Rider"
"Sublime Text"
"Atom"
"Neovide"
# Gaming
"Steam"
# Creative apps
"Blender"
"Godot"
"Unity"
"Unreal Editor"
)
# Music streaming services - browser tabs or electron apps
# These will be killed when focus apps are detected
MUSIC_SERVICES=(
# YouTube Music specific patterns (NOT regular YouTube)
"music.youtube.com"
"youtube-music" # Electron app
"YouTube Music" # Window title
# Spotify
"spotify"
"Spotify"
# Tidal
"tidal"
"TIDAL"
# Deezer
"deezer"
# Amazon Music
"Amazon Music"
"amazon music"
# Apple Music (web)
"music.apple.com"
# SoundCloud
"soundcloud.com"
# Pandora
"pandora.com"
)
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
# Sourced after the configuration above, which they read. music_detect.sh comes
# first: the monitor loops call its helpers.
source "$SCRIPT_DIR/lib/music_detect.sh"
source "$SCRIPT_DIR/lib/music_monitor.sh"
source "$SCRIPT_DIR/lib/music_status.sh"
# Main
case "${1:-instant}" in
monitor | start | run)
monitor_loop
;;
instant | fast)
instant_monitor_loop
;;
status)
show_status
;;
kill)
log_message "Manual kill requested"
if kill_music_services; then
echo "Music services killed"
else
echo "No music services found to kill"
fi
;;
help | -h | --help)
show_usage
;;
*)
echo "Unknown command: $1"
show_usage
exit 1
;;
esac

View File

@ -1,214 +0,0 @@
{
"name": "digital-wellbeing-tools",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "digital-wellbeing-tools",
"version": "1.0.0",
"dependencies": {
"classic-level": "^1.4.1"
}
},
"node_modules/abstract-level": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/abstract-level/-/abstract-level-1.0.4.tgz",
"integrity": "sha512-eUP/6pbXBkMbXFdx4IH2fVgvB7M0JvR7/lIL33zcs0IBcwjdzSSl31TOJsaCzmKSSDF9h8QYSOJux4Nd4YJqFg==",
"license": "MIT",
"dependencies": {
"buffer": "^6.0.3",
"catering": "^2.1.0",
"is-buffer": "^2.0.5",
"level-supports": "^4.0.0",
"level-transcoder": "^1.0.1",
"module-error": "^1.0.1",
"queue-microtask": "^1.2.3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/catering": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/catering/-/catering-2.1.1.tgz",
"integrity": "sha512-K7Qy8O9p76sL3/3m7/zLKbRkyOlSZAgzEaLhyj2mXS8PsCud2Eo4hAb8aLtZqHh0QGqLcb9dlJSu6lHRVENm1w==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/classic-level": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/classic-level/-/classic-level-1.4.1.tgz",
"integrity": "sha512-qGx/KJl3bvtOHrGau2WklEZuXhS3zme+jf+fsu6Ej7W7IP/C49v7KNlWIsT1jZu0YnfzSIYDGcEWpCa1wKGWXQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"abstract-level": "^1.0.2",
"catering": "^2.1.0",
"module-error": "^1.0.1",
"napi-macros": "^2.2.2",
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/is-buffer": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/level-supports": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/level-supports/-/level-supports-4.0.1.tgz",
"integrity": "sha512-PbXpve8rKeNcZ9C1mUicC9auIYFyGpkV9/i6g76tLgANwWhtG2v7I4xNBUlkn3lE2/dZF3Pi0ygYGtLc4RXXdA==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/level-transcoder": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/level-transcoder/-/level-transcoder-1.0.1.tgz",
"integrity": "sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w==",
"license": "MIT",
"dependencies": {
"buffer": "^6.0.3",
"module-error": "^1.0.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/module-error": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/module-error/-/module-error-1.0.2.tgz",
"integrity": "sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/napi-macros": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.2.2.tgz",
"integrity": "sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g==",
"license": "MIT"
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
}
}
}

View File

@ -1,10 +0,0 @@
{
"name": "digital-wellbeing-tools",
"version": "1.0.0",
"type": "module",
"description": "Node.js helpers for the digital-wellbeing scripts (e.g. LeechBlock storage seeder).",
"scripts": {},
"dependencies": {
"classic-level": "^1.4.1"
}
}

View File

@ -1,16 +0,0 @@
# Restore the pacman + makepkg wrapper symlinks after a pacman-git upgrade
# overwrites /usr/bin/pacman and /usr/bin/makepkg with the stock binaries.
# PostTransaction + file-ops-only Exec (never calls pacman) => safe inside the
# transaction. See rewrap_pkg_managers.sh.
[Trigger]
Type = Package
Operation = Upgrade
Operation = Install
Target = pacman
Target = pacman-git
[Action]
Description = Restoring pacman/makepkg wrapper symlinks...
When = PostTransaction
Exec = /usr/local/bin/rewrap_pkg_managers.sh
Depends = coreutils

View File

@ -1,138 +0,0 @@
# Pacman Wrapper Security System - LLM Reference Guide
> **For AI assistants**: This document explains the pacman wrapper architecture so you can make correct modifications.
## System Purpose
Intercept all `pacman` commands to:
1. Block installation of restricted packages (browsers, games, etc.)
2. Require challenges for greylisted packages
3. Enforce hosts file sharing on VirtualBox VMs
4. Auto-setup maintenance services if missing
5. Handle stale database locks gracefully
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────────┐
│ PACMAN WRAPPER │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ User runs: pacman -S firefox │
│ ↓ │
│ /usr/bin/pacman (symlink) → pacman_wrapper.sh │
│ ↓ │
│ 1. Verify policy file integrity (SHA256) │
│ 2. Check if package matches blocked keywords │
│ 3. Check if package requires challenge (greylist) │
│ 4. Run hosts-guard pre-unlock hook │
│ 5. Execute real pacman: /usr/bin/pacman.orig │
│ 6. Run hosts-guard post-relock hook │
│ 7. Remove any blocked packages that slipped through │
│ 8. Enforce VirtualBox hosts if vbox detected │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
## File Locations
| File | Purpose |
| --------------------------------------- | ---------------------------------- |
| `/usr/bin/pacman` | Symlink to wrapper |
| `/usr/bin/pacman.orig` | Real pacman binary |
| `pacman_wrapper.sh` | Main wrapper script (823 lines) |
| `install_pacman_wrapper.sh` | Installer script |
| `pacman_blocked_keywords.txt` | Substrings that cause blocking |
| `pacman_whitelist.txt` | Exact names that bypass blocking |
| `pacman_greylist.txt` | Packages requiring challenge |
| `words.txt` | Word scramble challenge dictionary |
| `/var/lib/pacman-wrapper/policy.sha256` | Integrity checksums |
## Policy Files Explained
### pacman_blocked_keywords.txt
```
# Lines starting with # are comments
# Any package containing these substrings is BLOCKED
firefox
brave
chromium
youtube
stremio
```
If user tries `pacman -S firefox-developer-edition`, it's blocked because it contains "firefox".
### pacman_whitelist.txt
```
# Exact package names that bypass keyword blocking
minizip # Contains nothing bad but might match a pattern
python-requests # Safe despite containing blocked substrings
```
### pacman_greylist.txt
```
# Packages requiring word scramble challenge
# Currently empty - add packages here for challenge requirement
```
## Hardcoded Security Checks
These checks are in the script itself and **cannot be bypassed by editing policy files**:
### VirtualBox Check
```bash
function is_virtualbox_package() {
local pkg_lower="${1,,}"
[[ $pkg_lower == *"virtualbox"* || $pkg_lower == *"vbox"* ]]
}
```
- Detects any package with "virtualbox" or "vbox" in name
- Requires word scramble challenge (7-letter words, 120s timeout)
- Auto-enforces hosts file sharing on all VMs after install
### Steam Check
```bash
function is_steam_package() {
[[ $1 == "steam" ]]
}
```
- Only exact match "steam" (not steam-native-runtime etc.)
- **Weekend only** - blocked Monday through Friday 4PM
- Requires word scramble challenge (5-letter words, 60s timeout)
## Word Scramble Challenge
Used for Steam, VirtualBox, and greylisted packages:
```
Challenge: Words with 5 letters
Here are 160 random words. Remember them:
APPLE BRAVE CHAIR DANCE ...
One of those words has been scrambled to: ELPPA
Unscramble the word to proceed (you have 60 seconds):
```
Parameters vary by package type:
| Package Type | Word Length | Words Shown | Timeout | Initial Delay |
| ------------ | ----------- | ----------- | ------- | ------------- |
| Steam | 5 | 160 | 60s | 0-20s |
| VirtualBox | 7 | 150 | 120s | 0-45s |
| Greylist | 6 | 120 | 90s | 0-30s |
## More detail
Split out to stay under the 250-line cap.
- [Integrity verification, cleanup and stale locks](llm-notes/verification-and-cleanup.md)
- [Maintenance auto-setup, known gaps and debugging](llm-notes/maintenance-and-debugging.md)

View File

@ -1,109 +0,0 @@
#!/bin/bash
# install_makepkg_wrapper.sh — wrap /usr/bin/makepkg so an orphaned pacman db.lck
# cannot hang `makepkg -i` forever (see makepkg_wrapper.sh for the full why).
#
# Mirrors install_pacman_wrapper.sh: preserves the real binary at
# /usr/bin/makepkg.orig and points /usr/bin/makepkg at the wrapper. Also installs
# the shared stale-lock library (idempotent) and the upgrade-survival hook that
# re-establishes BOTH wrapper symlinks after a pacman-git upgrade.
set -euo pipefail
# Auto-sudo
if [ "$EUID" -ne 0 ]; then
echo "Executing with sudo..."
exec sudo "$0" "$@"
fi
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
SRC_DIR="$(dirname "$(readlink -f "$0")")"
INSTALL_DIR="/usr/local/bin"
LOCK_LIB_SOURCE="$SRC_DIR/pacman_lock_lib.sh"
LOCK_LIB_DEST="${INSTALL_DIR}/pacman_lock_lib.sh"
WRAPPER_SOURCE="$SRC_DIR/makepkg_wrapper.sh"
WRAPPER_DEST="${INSTALL_DIR}/makepkg_wrapper"
REWRAP_SOURCE="$SRC_DIR/rewrap_pkg_managers.sh"
REWRAP_DEST="${INSTALL_DIR}/rewrap_pkg_managers.sh"
HOOK_SOURCE="$SRC_DIR/96-restore-pkg-wrappers.hook"
HOOK_DEST="/etc/pacman.d/hooks/96-restore-pkg-wrappers.hook"
SOURCE_MANIFEST="/var/lib/pacman-wrapper/makepkg-source.sha256"
# Copy a file, unlocking an immutable destination first (the shared lib may be
# chattr +i from install_pacman_wrapper.sh's integrity protection).
deploy_file() { # <src> <dest> <mode>
local src="$1" dest="$2" mode="$3"
if [[ ! -f $src ]]; then
echo -e "${RED}Error:${NC} missing source $src" >&2
exit 1
fi
if command -v chattr >/dev/null 2>&1 && [[ -e $dest ]] &&
[[ $(lsattr -d "$dest" 2>/dev/null | awk '{print $1}') == *i* ]]; then
chattr -i "$dest" 2>/dev/null || true
fi
cp -f "$src" "$dest"
chmod "$mode" "$dest"
}
echo -e "${CYAN}Installing makepkg wrapper...${NC}"
# Shared stale-lock library (idempotent — install_pacman_wrapper.sh also deploys
# and integrity-protects it; the makepkg wrapper needs it present regardless).
deploy_file "$LOCK_LIB_SOURCE" "$LOCK_LIB_DEST" 644
# makepkg wrapper itself
deploy_file "$WRAPPER_SOURCE" "$WRAPPER_DEST" 755
# Preserve the real makepkg. Refresh the backup whenever /usr/bin/makepkg is a
# real file (fresh install, or a pacman-git upgrade replaced our symlink); NEVER
# when it is already our symlink — copying the symlink's target would put the
# wrapper into makepkg.orig and cause an exec loop.
if [ ! -L /usr/bin/makepkg ]; then
echo -e "${BLUE}Backing up original makepkg to /usr/bin/makepkg.orig...${NC}"
cp -f /usr/bin/makepkg /usr/bin/makepkg.orig
fi
# Sanity: refuse to symlink if we have no real binary to fall back to.
if [ ! -e /usr/bin/makepkg.orig ]; then
echo -e "${RED}Error: /usr/bin/makepkg.orig missing; refusing to create wrapper symlink.${NC}" >&2
exit 1
fi
# Upgrade-survival: rewrap helper + PostTransaction hook restoring BOTH symlinks.
deploy_file "$REWRAP_SOURCE" "$REWRAP_DEST" 755
mkdir -p /etc/pacman.d/hooks
deploy_file "$HOOK_SOURCE" "$HOOK_DEST" 644
# Point /usr/bin/makepkg at the wrapper.
echo -e "${BLUE}Creating symbolic link /usr/bin/makepkg -> ${WRAPPER_DEST}...${NC}"
ln -sf "$WRAPPER_DEST" /usr/bin/makepkg
# Drift manifest — see deployment_drift() in check_and_enable_services.sh. Same
# rationale as the pacman wrapper's: "the file exists" is not "the right version
# is deployed", and only the second one is worth re-checking every hour.
mkdir -p "$(dirname "$SOURCE_MANIFEST")"
{
sha256sum "$WRAPPER_SOURCE" "$LOCK_LIB_SOURCE" "$REWRAP_SOURCE" "$HOOK_SOURCE" || {
echo -e "${RED}Failed to checksum makepkg wrapper sources${NC}" >&2
exit 1
}
sha256sum "$WRAPPER_DEST" "$REWRAP_DEST" "$HOOK_DEST" || {
echo -e "${RED}Failed to checksum installed makepkg wrapper${NC}" >&2
exit 1
}
} >"$SOURCE_MANIFEST"
if [[ ! -s $SOURCE_MANIFEST ]]; then
echo -e "${RED}Error: drift manifest was not created or is empty${NC}" >&2
exit 1
fi
chmod 644 "$SOURCE_MANIFEST"
echo -e "${GREEN}makepkg wrapper installed.${NC} Original preserved at ${CYAN}/usr/bin/makepkg.orig${NC}"
echo -e "Upgrade-survival hook installed at ${CYAN}${HOOK_DEST}${NC}"

View File

@ -1,134 +0,0 @@
#!/bin/bash
# filepath: /home/kuhy/linux-configuration/scripts/install_pacman_wrapper.sh
#
# The steps live in lib/; this file owns the paths, the root check, the EXIT
# trap, the /usr/bin handling and the call order. Two blocks stay here
# deliberately
# because the trace harness cannot execute them (it cannot bind /usr/bin), so
# a split that moved them could not be verified: the pacman.orig backup plus
# the sed rewrite, and everything from the symlink onwards.
set -euo pipefail
# Auto-sudo functionality
if [ "$EUID" -ne 0 ]; then
echo "Executing with sudo..."
sudo "$0" "$@"
exit $?
fi
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Script locations. These stay in the entry script rather than moving to a lib:
# a definitions-only lib assigns without referencing, which is SC2034, and the
# repo forbids suppressions. The libs only reference these globals, a shape the
# linter accepts. `$0` is the entry script either way.
WRAPPER_SOURCE="$(dirname "$0")/pacman_wrapper.sh"
LOCK_LIB_SOURCE="$(dirname "$0")/pacman_lock_lib.sh"
# The wrapper's own phase libraries, deployed FLAT beside it (same shape as
# pacman_lock_lib.sh). Named explicitly rather than globbed so a stray pw_*.sh
# in a working tree cannot be deployed by accident.
PW_LIBS=(pw_policy pw_guard pw_ui pw_challenge pw_cleanup)
WORDS_SOURCE="$(dirname "$0")/words.txt"
BLOCKED_SOURCE="$(dirname "$0")/pacman_blocked_keywords.txt"
WHITELIST_SOURCE="$(dirname "$0")/pacman_whitelist.txt"
GREYLIST_SOURCE="$(dirname "$0")/pacman_greylist.txt"
MAKEPKG_CAPPED_SOURCE="$(dirname "$0")/makepkg_capped.sh"
MKPKG_SOURCE="$(dirname "$0")/mkpkg.sh"
HEAVY_LOCK_SOURCE="$(dirname "$0")/../heavy_job_lock.sh"
INSTALL_DIR="/usr/local/bin"
WRAPPER_DEST="${INSTALL_DIR}/pacman_wrapper"
LOCK_LIB_DEST="${INSTALL_DIR}/pacman_lock_lib.sh"
WORDS_DEST="${INSTALL_DIR}/words.txt"
BLOCKED_DEST="${INSTALL_DIR}/pacman_blocked_keywords.txt"
WHITELIST_DEST="${INSTALL_DIR}/pacman_whitelist.txt"
GREYLIST_DEST="${INSTALL_DIR}/pacman_greylist.txt"
MAKEPKG_CAPPED_DEST="${INSTALL_DIR}/makepkg_capped"
MKPKG_DEST="${INSTALL_DIR}/mkpkg"
HEAVY_LOCK_DEST="${INSTALL_DIR}/heavy_job_lock.sh"
INTEGRITY_DIR="/var/lib/pacman-wrapper"
INTEGRITY_FILE="${INTEGRITY_DIR}/policy.sha256"
SOURCE_MANIFEST="${INTEGRITY_DIR}/source.sha256"
LEECHBLOCK_INSTALLER_SOURCE="$(dirname "$0")/../install_leechblock.sh"
LEECHBLOCK_DEFAULTS_SOURCE="$(dirname "$0")/../leechblock_defaults.json"
LEECHBLOCK_SEEDER_SOURCE="$(dirname "$0")/../seed_leechblock_storage.js"
LEECHBLOCK_PKG_SOURCE="$(dirname "$0")/../package.json"
LEECHBLOCK_INSTALL_DIR="/usr/local/share/digital_wellbeing"
LEECHBLOCK_INSTALLER_DEST="${LEECHBLOCK_INSTALL_DIR}/install_leechblock.sh"
LEECHBLOCK_DEFAULTS_DEST="${LEECHBLOCK_INSTALL_DIR}/leechblock_defaults.json"
LEECHBLOCK_SEEDER_DEST="${LEECHBLOCK_INSTALL_DIR}/seed_leechblock_storage.js"
LEECHBLOCK_LIB_SOURCE="$(dirname "$0")/../lib"
LEECHBLOCK_LIB_DEST="${LEECHBLOCK_INSTALL_DIR}/lib"
VBOX_ENFORCE_SOURCE="$(dirname "$0")/../virtualbox/enforce_vbox_hosts.sh"
VBOX_INSTALL_DIR="/usr/local/share/digital_wellbeing/virtualbox"
VBOX_ENFORCE_DEST="${VBOX_INSTALL_DIR}/enforce_vbox_hosts.sh"
# readlink -f so a symlinked entry point still finds lib/.
SCRIPT_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
# shellcheck source=lib/managed_copy.sh
source "$SCRIPT_DIR/lib/managed_copy.sh"
# shellcheck source=lib/install_files.sh
source "$SCRIPT_DIR/lib/install_files.sh"
# shellcheck source=lib/integrity.sh
source "$SCRIPT_DIR/lib/integrity.sh"
# shellcheck source=lib/protect_and_extras.sh
source "$SCRIPT_DIR/lib/protect_and_extras.sh"
trap relock_files_on_exit EXIT
# Check if script is run as root
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Please run as root${NC}"
exit 1
fi
# Check if the wrapper script exists
if [ ! -f "$WRAPPER_SOURCE" ]; then
echo -e "${RED}Error: Wrapper script not found at ${WRAPPER_SOURCE}${NC}"
exit 1
fi
install_managed_files
# Automatically use symbolic link installation method
echo -e "${YELLOW}Installing using symbolic link method...${NC}"
# Backup original pacman. Refresh the backup whenever /usr/bin/pacman is a real
# file (e.g. a pacman-git upgrade replaced our symlink with the new binary), but
# NEVER when it is already our symlink — copying the symlink's target would put
# the wrapper into pacman.orig and cause an exec loop.
if [ ! -L /usr/bin/pacman ]; then
echo -e "${BLUE}Backing up original pacman to /usr/bin/pacman.orig...${NC}"
cp -f /usr/bin/pacman /usr/bin/pacman.orig
fi
# Update the PACMAN_BIN variable in the wrapper to point to the original
sed -i 's|PACMAN_BIN="\/usr\/bin\/pacman"|PACMAN_BIN="\/usr\/bin\/pacman.orig"|g' "$WRAPPER_DEST"
write_policy_integrity_file
write_drift_manifest
protect_policy_files
install_leechblock_payload
install_vbox_enforcement
# Create symbolic link
echo -e "${BLUE}Creating symbolic link...${NC}"
ln -sf "$WRAPPER_DEST" /usr/bin/pacman
echo -e "${GREEN}Installation complete!${NC}"
echo -e "Pacman is now wrapped. The original pacman is available at ${CYAN}/usr/bin/pacman.orig${NC}"
if [ -f "$MAKEPKG_CAPPED_DEST" ]; then
echo -e "Run constrained package builds with: ${CYAN}pacman --makepkg-capped <args>${NC}"
fi
if [ -f "$MKPKG_DEST" ]; then
echo -e "Shortcut available: ${CYAN}mkpkg <args>${NC}"
fi
echo -e "${CYAN}Policy files are now protected with immutable attributes.${NC}"
if [ -f "$VBOX_ENFORCE_DEST" ]; then
echo -e "${CYAN}VirtualBox VMs will automatically be configured to use host's /etc/hosts.${NC}"
fi

View File

@ -1,40 +0,0 @@
#!/bin/bash
# Copies the wrapper, policy lists and helper scripts into /usr/local/bin.
# Sourced by install_pacman_wrapper.sh; inherits the caller's strict mode.
install_managed_files() {
echo -e "${CYAN}Installing pacman wrapper...${NC}"
# Install the wrapper script
echo -e "${BLUE}Copying wrapper script to ${WRAPPER_DEST}...${NC}"
copy_managed_file "$WRAPPER_SOURCE" "$WRAPPER_DEST" required "wrapper script"
copy_managed_file "$LOCK_LIB_SOURCE" "$LOCK_LIB_DEST" required "stale-lock library"
chmod 644 "$LOCK_LIB_DEST"
# The wrapper's phase libraries. They land flat beside the wrapper because
# pacman_wrapper.sh resolves them from its own directory, which is
# /usr/local/bin once installed. Required, not optional: without them the
# wrapper still runs but falls open to unwrapped pacman, so a partial
# install would silently disable every policy check.
local pw_lib
for pw_lib in "${PW_LIBS[@]}"; do
copy_managed_file "$(dirname "$WRAPPER_SOURCE")/${pw_lib}.sh" \
"${INSTALL_DIR}/${pw_lib}.sh" required "wrapper library ${pw_lib}"
chmod 644 "${INSTALL_DIR}/${pw_lib}.sh"
done
copy_managed_file "$WORDS_SOURCE" "$WORDS_DEST" required "words list"
copy_managed_file "$BLOCKED_SOURCE" "$BLOCKED_DEST" required "blocked keywords list"
copy_managed_file "$WHITELIST_SOURCE" "$WHITELIST_DEST" optional "whitelist"
copy_managed_file "$GREYLIST_SOURCE" "$GREYLIST_DEST" required "greylist"
chmod +x "$WRAPPER_DEST"
copy_managed_file "$MAKEPKG_CAPPED_SOURCE" "$MAKEPKG_CAPPED_DEST" required "makepkg capped wrapper"
chmod +x "$MAKEPKG_CAPPED_DEST"
copy_managed_file "$MKPKG_SOURCE" "$MKPKG_DEST" required "mkpkg helper"
chmod +x "$MKPKG_DEST"
# The heavy-job lock is sourced by pacman_wrapper.sh and makepkg_capped.sh from
# a fixed absolute path, so it has to land next to them rather than be read out
# of a repo checkout that may move.
copy_managed_file "$HEAVY_LOCK_SOURCE" "$HEAVY_LOCK_DEST" required "heavy-job lock library"
chmod 755 "$HEAVY_LOCK_DEST"
chmod 644 "$WORDS_DEST" "$BLOCKED_DEST" "$WHITELIST_DEST" "$GREYLIST_DEST" 2>/dev/null || true
}

View File

@ -1,125 +0,0 @@
#!/bin/bash
# Policy integrity file (chattr +i) and the deployment drift manifest.
# Sourced by install_pacman_wrapper.sh; inherits the caller's strict mode.
#
# test_pacman_wrapper_security.sh greps THIS file's source text for the literal
# `sha256sum "$LOCK_LIB_DEST"` -- it asserts the stale-lock library is covered
# by the integrity manifest. Keep that line verbatim.
# Writes $INTEGRITY_FILE and makes it immutable. Exits non-zero if any policy
# file is missing, because a partial integrity file is worse than none.
write_policy_integrity_file() {
# Create integrity directory if it doesn't exist
mkdir -p "$INTEGRITY_DIR"
chmod 755 "$INTEGRITY_DIR"
# Generate checksums of policy files for integrity verification
echo -e "${BLUE}Generating integrity checksums for policy files...${NC}"
unlock_immutable_file_if_needed "$INTEGRITY_FILE"
# Ensure all critical policy files exist before checksumming
missing_files=()
[[ ! -f "$BLOCKED_DEST" ]] && missing_files+=("$BLOCKED_DEST")
[[ ! -f "$GREYLIST_DEST" ]] && missing_files+=("$GREYLIST_DEST")
[[ ! -f "$LOCK_LIB_DEST" ]] && missing_files+=("$LOCK_LIB_DEST")
if [[ ${#missing_files[@]} -gt 0 ]]; then
echo -e "${RED}Error: Critical policy files are missing:${NC}"
printf '%s\n' "${missing_files[@]}" >&2
echo -e "${RED}Installation incomplete. Cannot create integrity file.${NC}"
exit 1
fi
{
sha256sum "$BLOCKED_DEST" || {
echo -e "${RED}Failed to checksum blocked list${NC}" >&2
exit 1
}
sha256sum "$GREYLIST_DEST" || {
echo -e "${RED}Failed to checksum greylist${NC}" >&2
exit 1
}
# The shared stale-lock library is executed (sourced) by the wrapper, so it is
# integrity-checked too: pacman_wrapper.sh sources it only AFTER
# verify_policy_integrity passes, so a tampered lib is rejected before it runs.
sha256sum "$LOCK_LIB_DEST" || {
echo -e "${RED}Failed to checksum lock library${NC}" >&2
exit 1
}
# Same reasoning for the wrapper's phase libraries: they are sourced by
# pacman_wrapper.sh, so an unchecksummed lib would be an unguarded way
# to run code inside the wrapper. They are sourced BEFORE
# verify_policy_integrity (the fast path needs needs_unlock), so this
# manifest entry is what makes tampering detectable rather than what
# prevents the source — the check still refuses the transaction.
for pw_lib in "${PW_LIBS[@]}"; do
sha256sum "${INSTALL_DIR}/${pw_lib}.sh" || {
echo -e "${RED}Failed to checksum wrapper library ${pw_lib}${NC}" >&2
exit 1
}
done
# Whitelist is optional
if [[ -f "$WHITELIST_DEST" ]]; then
sha256sum "$WHITELIST_DEST" || {
echo -e "${RED}Failed to checksum whitelist${NC}" >&2
exit 1
}
fi
} >"$INTEGRITY_FILE"
# Verify integrity file was created and has content
if [[ ! -s "$INTEGRITY_FILE" ]]; then
echo -e "${RED}Error: Integrity file was not created or is empty${NC}"
exit 1
fi
# Make integrity file immutable
chmod 400 "$INTEGRITY_FILE"
if command -v chattr >/dev/null 2>&1; then
chattr +i "$INTEGRITY_FILE" 2>/dev/null || echo -e "${YELLOW}Warning: Could not make integrity file immutable${NC}"
fi
}
# Record a drift manifest: the hash of every SOURCE file we installed from, and
# of the installed copies we produced. check_and_enable_services.sh replays it
# with `sha256sum -c`, which answers both questions in one command:
# - a source line fails => the repo moved on, this deployment is stale
# - a dest line fails => someone edited the installed copy directly
# Neither was detectable before: the checker only tested that files EXIST, which
# is why a wrapper 7.6 KB behind the repo (no integrity manifest, no
# pacman_lock_lib, no guard-lib fallbacks) ran unnoticed for a week while the
# hourly maintenance timer kept reporting "ok".
# Deliberately NOT chattr +i (unlike the policy integrity file above): this is a
# drift record, not a security boundary, and it must be rewritable each install.
write_drift_manifest() {
echo -e "${BLUE}Recording deployment drift manifest...${NC}"
# ABSOLUTE paths only. The SOURCE_* vars are built from `dirname "$0"`, so
# they are relative whenever the installer is invoked by a relative path — and
# check_and_enable_services.sh replays this manifest from systemd with cwd=/,
# where relative entries resolve to nothing. sha256sum -c would report those as
# failures, the checker would read that as drift, and it would reinstall on
# every single hourly run forever.
manifest_sources=()
for src in "$WRAPPER_SOURCE" "$LOCK_LIB_SOURCE" "$BLOCKED_SOURCE" "$GREYLIST_SOURCE" \
"$MAKEPKG_CAPPED_SOURCE" "$MKPKG_SOURCE" "$WHITELIST_SOURCE"; do
[[ -f "$src" ]] || continue # whitelist is optional
manifest_sources+=("$(readlink -f "$src")")
done
{
sha256sum "${manifest_sources[@]}" || {
echo -e "${RED}Failed to checksum wrapper sources${NC}" >&2
exit 1
}
sha256sum "$LOCK_LIB_DEST" || {
echo -e "${RED}Failed to checksum installed lock lib${NC}" >&2
exit 1
}
} >"$SOURCE_MANIFEST"
if [[ ! -s "$SOURCE_MANIFEST" ]]; then
echo -e "${RED}Error: drift manifest was not created or is empty${NC}" >&2
exit 1
fi
chmod 644 "$SOURCE_MANIFEST"
}

View File

@ -1,55 +0,0 @@
#!/bin/bash
# Immutable-attribute handling and the guarded copy used for every managed file.
# Sourced by install_pacman_wrapper.sh; inherits the caller's strict mode.
#
# RELock_FILES is a global on purpose: copy_managed_file appends to it and the
# EXIT trap in the entry script drains it. Its capitalization is preserved from
# before the split.
declare -a RELock_FILES=()
is_immutable_file() {
local file_path="$1"
[[ -e "$file_path" ]] || return 1
[[ $(lsattr -d "$file_path" 2>/dev/null | awk '{print $1}') == *i* ]]
}
unlock_immutable_file_if_needed() {
local file_path="$1"
if ! command -v chattr >/dev/null 2>&1; then
return 0
fi
if is_immutable_file "$file_path"; then
chattr -i "$file_path"
RELock_FILES+=("$file_path")
fi
}
relock_files_on_exit() {
if ! command -v chattr >/dev/null 2>&1; then
return
fi
for file_path in "${RELock_FILES[@]}"; do
[[ -e "$file_path" ]] || continue
chattr +i "$file_path" 2>/dev/null || true
done
}
copy_managed_file() {
local source_file="$1"
local dest_file="$2"
local required="$3"
local label="$4"
if [[ ! -f "$source_file" ]]; then
if [[ "$required" == "required" ]]; then
echo -e "${RED}Error:${NC} Missing required ${label} at ${source_file}" >&2
exit 1
fi
echo -e "${YELLOW}Warning:${NC} Missing ${label} at ${source_file}" >&2
return
fi
unlock_immutable_file_if_needed "$dest_file"
cp "$source_file" "$dest_file"
}

View File

@ -1,70 +0,0 @@
#!/bin/bash
# Policy-file immutability, plus the LeechBlock and VirtualBox payloads.
# Sourced by install_pacman_wrapper.sh; inherits the caller's strict mode.
# Make policy files immutable to prevent easy tampering
protect_policy_files() {
echo -e "${BLUE}Protecting policy files from modification...${NC}"
if command -v chattr >/dev/null 2>&1; then
chattr +i "$BLOCKED_DEST" 2>/dev/null || echo -e "${YELLOW}Warning: Could not make blocked list immutable${NC}"
chattr +i "$GREYLIST_DEST" 2>/dev/null || echo -e "${YELLOW}Warning: Could not make greylist immutable${NC}"
chattr +i "$LOCK_LIB_DEST" 2>/dev/null || echo -e "${YELLOW}Warning: Could not make lock library immutable${NC}"
# Note: whitelist is intentionally left modifiable for user convenience
else
echo -e "${YELLOW}Warning: chattr not available, policy files will not be immutable${NC}"
fi
}
# Install LeechBlock installer and defaults if available
install_leechblock_payload() {
mkdir -p "$LEECHBLOCK_INSTALL_DIR"
if [ -f "$LEECHBLOCK_INSTALLER_SOURCE" ]; then
echo -e "${BLUE}Installing LeechBlock installer to ${LEECHBLOCK_INSTALLER_DEST}...${NC}"
cp "$LEECHBLOCK_INSTALLER_SOURCE" "$LEECHBLOCK_INSTALLER_DEST"
chmod +x "$LEECHBLOCK_INSTALLER_DEST"
# The installer sources every install phase from lib/ beside itself, so
# the directory has to travel with it. Deploying the entry script alone
# leaves a copy that dies on its first `source`. Mirror rather than
# merge, so a lib deleted upstream does not linger here.
if [ -d "$LEECHBLOCK_LIB_SOURCE" ]; then
rm -rf "$LEECHBLOCK_LIB_DEST"
mkdir -p "$LEECHBLOCK_LIB_DEST"
# Only the leechblock_* libs: lib/ is shared with unrelated scripts
# (the music_* helpers) that this payload has no business shipping.
cp "$LEECHBLOCK_LIB_SOURCE"/leechblock_*.sh "$LEECHBLOCK_LIB_DEST"/
chmod +x "$LEECHBLOCK_LIB_DEST"/leechblock_*.sh
echo -e "${GREEN}LeechBlock libs deployed to ${LEECHBLOCK_LIB_DEST}${NC}"
else
echo -e "${YELLOW}LeechBlock lib/ not found at ${LEECHBLOCK_LIB_SOURCE}; the deployed installer will not run${NC}"
fi
echo -e "${GREEN}LeechBlock installer deployed to ${LEECHBLOCK_INSTALLER_DEST}${NC}"
else
echo -e "${YELLOW}LeechBlock installer not found at ${LEECHBLOCK_INSTALLER_SOURCE}, skipping...${NC}"
fi
if [ -f "$LEECHBLOCK_DEFAULTS_SOURCE" ]; then
cp "$LEECHBLOCK_DEFAULTS_SOURCE" "$LEECHBLOCK_DEFAULTS_DEST"
echo -e "${GREEN}LeechBlock defaults deployed to ${LEECHBLOCK_DEFAULTS_DEST}${NC}"
fi
if [ -f "$LEECHBLOCK_SEEDER_SOURCE" ]; then
cp "$LEECHBLOCK_SEEDER_SOURCE" "$LEECHBLOCK_SEEDER_DEST"
echo -e "${GREEN}LeechBlock seeder deployed to ${LEECHBLOCK_SEEDER_DEST}${NC}"
fi
if [ -f "$LEECHBLOCK_PKG_SOURCE" ]; then
cp "$LEECHBLOCK_PKG_SOURCE" "${LEECHBLOCK_INSTALL_DIR}/package.json"
echo -e "${BLUE}Installing Node.js deps in ${LEECHBLOCK_INSTALL_DIR}...${NC}"
npm install --prefix "$LEECHBLOCK_INSTALL_DIR" 2>&1 | grep -v '^npm warn' || true
fi
}
# Install VirtualBox enforcement script if available
install_vbox_enforcement() {
if [ -f "$VBOX_ENFORCE_SOURCE" ]; then
echo -e "${BLUE}Installing VirtualBox hosts enforcement script...${NC}"
mkdir -p "$VBOX_INSTALL_DIR"
cp "$VBOX_ENFORCE_SOURCE" "$VBOX_ENFORCE_DEST"
chmod +x "$VBOX_ENFORCE_DEST"
echo -e "${GREEN}VirtualBox enforcement script installed to ${VBOX_ENFORCE_DEST}${NC}"
else
echo -e "${YELLOW}VirtualBox enforcement script not found, skipping...${NC}"
fi
}

View File

@ -1,56 +0,0 @@
# Maintenance auto-setup, known gaps and debugging
## Maintenance Auto-Setup
On first run, wrapper checks if periodic maintenance services exist:
```bash
ensure_periodic_maintenance() {
# Checks: periodic-system-maintenance.timer
# periodic-system-startup.service
# hosts-file-monitor.service
# If missing: runs setup_periodic_system.sh
}
```
## Known Gaps (TODO)
1. ❌ `google-chrome` and `google-chrome-stable` not in blocked list
2. ❌ No automatic LeechBlock installation when browsers detected
3. ❌ User can download and install `.deb`/`.tar.gz` manually
4. ❌ AUR packages bypass wrapper (yay/paru call pacman internally)
## Debugging
### Check if wrapper is installed
```bash
ls -la /usr/bin/pacman
# Should show: /usr/bin/pacman -> /path/to/pacman_wrapper.sh
ls -la /usr/bin/pacman.orig
# Should exist and be the real binary
```
### Test policy integrity
```bash
cat /var/lib/pacman-wrapper/policy.sha256
sha256sum /path/to/pacman_blocked_keywords.txt
# Hashes should match
```
### Verbose mode
The wrapper outputs colored status messages to stderr. To see them:
```bash
pacman -S package 2>&1 | cat
```
## DO NOT
1. ❌ Edit policy files without reinstalling wrapper (breaks integrity check)
2. ❌ Remove `/usr/bin/pacman.orig` (breaks all pacman operations)
3. ❌ Symlink pacman to something other than the wrapper
4. ❌ Clear `/var/lib/pacman-wrapper/` without understanding consequences

View File

@ -1,154 +0,0 @@
# Integrity verification, cleanup and stale locks
## Integrity Verification
On every invocation, the wrapper verifies policy files haven't been tampered with:
```bash
verify_policy_integrity() {
# Reads /var/lib/pacman-wrapper/policy.sha256
# Compares SHA256 of each policy file
# If mismatch: BLOCKS all operations
}
```
If tampering detected:
```
SECURITY WARNING: Policy file integrity check failed!
CRITICAL: Policy files have been tampered with!
Wrapper operation DENIED. Please reinstall using: sudo install_pacman_wrapper.sh
```
## Hosts Integration
The wrapper integrates with the hosts guard system:
```bash
pre_unlock_hosts() {
# Called before any transaction (-S, -U, -R)
/usr/local/share/hosts-guard/pacman-pre-unlock-hosts.sh
}
post_relock_hosts() {
# Called after transaction completes
/usr/local/share/hosts-guard/pacman-post-relock-hosts.sh
}
```
This allows package installations to modify `/etc/hosts` temporarily (e.g., for network setup) while maintaining protection.
## Common Tasks
### Adding a Blocked Package
1. Edit `pacman_blocked_keywords.txt`:
```bash
echo "newkeyword" >> pacman_blocked_keywords.txt
```
2. Reinstall wrapper to update checksums:
```bash
sudo ./install_pacman_wrapper.sh
```
### Whitelisting a Package
If a legitimate package is being blocked (e.g., `python-firefox-sync` blocked by "firefox" keyword):
1. Edit `pacman_whitelist.txt`:
```bash
echo "python-firefox-sync" >> pacman_whitelist.txt
```
2. Reinstall wrapper:
```bash
sudo ./install_pacman_wrapper.sh
```
### Adding a Challenge Requirement
1. Edit `pacman_greylist.txt`:
```bash
echo "suspicious-package" >> pacman_greylist.txt
```
2. Reinstall wrapper.
### Bypassing the Wrapper (Emergency)
If wrapper is broken and you need real pacman:
```bash
sudo /usr/bin/pacman.orig -S package
```
**Warning**: This bypasses all security checks.
## Post-Transaction Cleanup
After every transaction, the wrapper:
1. Scans installed packages for blocked keywords
2. Removes any that match (shouldn't happen normally)
3. Scans for greylisted packages and removes them
4. Checks if VirtualBox is installed and enforces hosts
```bash
remove_installed_blocked_packages() {
mapfile -t installed_names < <("$PACMAN_BIN" -Qq)
for name in "${installed_names[@]}"; do
if is_blocked_package_name "$name"; then
pacman -Rns --noconfirm "$name"
fi
done
}
```
## Stale Lock Handling
The stale-lock logic lives in **`pacman_lock_lib.sh`** (shared, sourced by both
`pacman_wrapper.sh` and `makepkg_wrapper.sh` — single source of truth). The
wrapper sources it only AFTER `verify_policy_integrity`, and the lib is listed in
the integrity manifest, so a tampered lib is rejected before it runs.
If `/var/lib/pacman/db.lck` exists but no pacman is running:
- Interactive: Prompts user to remove (15s timeout)
- Non-interactive (`--noconfirm`): Auto-removes; also auto-removes if lock is
> 10 minutes old
- If a real pacman/pamac process is running: Blocks with error. Detection uses
`fuser`/`lsof` on the lock file AND a system-wide `pgrep -x pacman` guard
(`pacman_process_running`) so an UNPRIVILEGED caller (makepkg is never root)
can still see a ROOT `pacman -Syu` that its own `fuser` cannot.
### Makepkg wrapper
`/usr/bin/makepkg` is symlinked to `makepkg_wrapper` (real binary at
`/usr/bin/makepkg.orig`). Vendored `makepkg`'s `run_pacman()` has its own
lock-wait that checks ONLY file existence with no timeout, so an orphaned
`db.lck` hangs `makepkg -i` forever ("Pacman is currently in use, please
wait...") _before_ pacman is ever called — the pacman wrapper's cleanup never
gets a turn. `makepkg_wrapper` closes this: for install-bound invocations
(`-i`/`--install`) it clears an orphaned lock up front, then execs real makepkg.
It bypasses (execs real makepkg unchanged) inside a fakeroot build sandbox
(`FAKEROOTKEY` set) or for non-install invocations, and **fails open** — if the
shared lib is missing it execs real makepkg rather than break all builds.
### Upgrade survival
A `pacman-git` upgrade reinstalls `/usr/bin/pacman` and `/usr/bin/makepkg` as
stock binaries, clobbering the wrapper symlinks. Two mechanisms restore them:
- **PostTransaction hook** `/etc/pacman.d/hooks/96-restore-pkg-wrappers.hook`
(Target = pacman/pacman-git) runs `rewrap_pkg_managers.sh` (file-ops only,
never calls pacman) to re-establish both symlinks and refresh the `.orig`
backups. NOTE: alpm `Operation` accepts only Install/Upgrade/Remove — an
invalid value aborts EVERY transaction; validate with a real `-S` install.
- **Drift verifier** `check_makepkg_wrapper()` in `check_and_enable_services.sh`
plus the hourly periodic driver re-running both installers.

View File

@ -1,58 +0,0 @@
#!/bin/bash
# Run makepkg inside a constrained user scope to protect desktop responsiveness.
set -euo pipefail
CPU_QUOTA="${MAKEPKG_CPU_QUOTA:-70%}"
MEMORY_MAX="${MAKEPKG_MEMORY_MAX:-12G}"
TASKS_MAX="${MAKEPKG_TASKS_MAX:-2048}"
NICE_LEVEL="${MAKEPKG_NICE_LEVEL:-10}"
IONICE_CLASS="${MAKEPKG_IONICE_CLASS:-2}"
IONICE_LEVEL="${MAKEPKG_IONICE_LEVEL:-7}"
if ! command -v makepkg >/dev/null 2>&1; then
echo "makepkg_capped: makepkg not found in PATH" >&2
exit 127
fi
# Serialise against other heavy jobs (Gradle/Flutter builds, coverage runs,
# pacman transactions) on this 7.6 GB machine. Re-exec ourselves under the lock
# instead of wrapping each branch below, so all three fallback paths are covered
# by one guard. HEAVY_JOB_LOCK_HELD keeps the re-exec from recursing.
# Note the cgroup caps below are NOT a substitute: MemoryMax defaults to 12G on
# a machine with 7.6G, so they bound one build's shape, not the machine's total.
HEAVY_JOB_LOCK_LIB="/usr/local/bin/heavy_job_lock.sh"
if [[ -r $HEAVY_JOB_LOCK_LIB && -z ${HEAVY_JOB_LOCK_HELD:-} ]]; then
# shellcheck source=/dev/null
source "$HEAVY_JOB_LOCK_LIB"
export HEAVY_JOB_LOCK_HELD=1
with_heavy_lock makepkg -- "$0" "$@"
exit $?
fi
run_makepkg_fallback() {
exec ionice -c "$IONICE_CLASS" -n "$IONICE_LEVEL" \
nice -n "$NICE_LEVEL" \
makepkg "$@"
}
if ! command -v systemd-run >/dev/null 2>&1; then
echo "makepkg_capped: systemd-run unavailable, falling back to local limits" >&2
run_makepkg_fallback "$@"
fi
# Keep builds in the user manager scope. If this fails (no user manager),
# gracefully fall back to ionice+nice only.
if ! systemd-run --user --scope --quiet true 2>/dev/null; then
echo "makepkg_capped: user systemd scope unavailable, falling back" >&2
run_makepkg_fallback "$@"
fi
exec systemd-run --user --scope --quiet --same-dir --collect \
-p "CPUQuota=${CPU_QUOTA}" \
-p "MemoryMax=${MEMORY_MAX}" \
-p "MemorySwapMax=0" \
-p "TasksMax=${TASKS_MAX}" \
ionice -c "$IONICE_CLASS" -n "$IONICE_LEVEL" \
nice -n "$NICE_LEVEL" \
makepkg "$@"

View File

@ -1,69 +0,0 @@
#!/bin/bash
# makepkg_wrapper.sh — thin wrapper around /usr/bin/makepkg.
#
# WHY THIS EXISTS
# ---------------
# Vendored makepkg (shipped by the `pacman` package) has its own DB-lock wait in
# run_pacman(): `while [[ -f $lockfile ]]; do ... done`. It checks ONLY whether
# /var/lib/pacman/db.lck exists — never whether a live pacman actually owns it —
# and the outer loop has no timeout. So an ORPHANED lock (left by a pacman that
# was killed mid-transaction) makes `makepkg -i` hang forever, printing
# "Pacman is currently in use, please wait..." every ~30s.
#
# makepkg blocks in that loop BEFORE it ever invokes pacman, so the system's
# pacman_wrapper (which clears stale locks correctly) never gets a turn. This
# wrapper closes the gap: for install-bound invocations it clears an *orphaned*
# lock up front, then execs the real makepkg. A legitimate lock (held by a live
# pacman) is left untouched so makepkg waits as designed.
#
# makepkg is vendored and overwritten on `pacman-git` upgrades, so it must not be
# edited directly — wrapping it (mirroring the pacman_wrapper pattern) is the
# upgrade-safe fix.
set -euo pipefail
# Real makepkg, preserved by install_makepkg_wrapper.sh. Overridable via env so
# the shell test harness can point it at a stub (mirrors $PACMAN_LOCK_FILE);
# unset in production, so the default holds.
MAKEPKG_BIN="${MAKEPKG_BIN:-/usr/bin/makepkg.orig}"
# Source shared stale-lock helpers (co-located next to this wrapper). Fail SAFE:
# if the lib is missing/unreadable, do NOT break makepkg — exec the real binary
# and skip the pre-flight. A broken helper must never take down all builds.
_LIB_DIR="$(dirname "$(readlink -f "$0")")"
if [[ -r "$_LIB_DIR/pacman_lock_lib.sh" ]]; then
# shellcheck source=pacman_lock_lib.sh
source "$_LIB_DIR/pacman_lock_lib.sh"
else
exec "$MAKEPKG_BIN" "$@"
fi
# Does this invocation ask makepkg to install (-i / --install)? Only install
# invocations reach makepkg's pacman lock-wait, so only then do we pre-clear.
# Handles bundled short flags (-i, -si, -sif, -if, ...). Stops at `--`.
invocation_installs() {
local arg
for arg in "$@"; do
[[ $arg == "--" ]] && return 1
[[ $arg == "--install" ]] && return 0
# Single-dash short-flag cluster containing 'i' (makepkg's -i = install).
[[ $arg == -[!-]* && $arg == *i* ]] && return 0
done
return 1
}
# Bypass fast-path: inside a fakeroot build sandbox (makepkg re-execs itself for
# the package() stage — FAKEROOTKEY is set), or for any invocation that won't
# install (won't hit the lock-wait). Mirrors the pacman_wrapper FAKEROOTKEY
# guard. Exec real makepkg unchanged.
if [[ -n ${FAKEROOTKEY:-} ]] || ! invocation_installs "$@"; then
exec "$MAKEPKG_BIN" "$@"
fi
# Best-effort pre-flight clear. `check_and_handle_db_lock` removes the lock only
# when no live pacman owns it; if a real pacman holds it, it returns non-zero and
# we deliberately DO NOT abort — makepkg's own wait will (correctly) wait it out.
# Hence `|| true`: a live-holder verdict is not a makepkg failure here.
check_and_handle_db_lock "$@" || true
exec "$MAKEPKG_BIN" "$@"

View File

@ -1,8 +0,0 @@
#!/bin/bash
# Convenience wrapper for constrained Arch package builds.
set -euo pipefail
PACMAN_WRAPPER_BIN="/usr/bin/pacman"
exec "$PACMAN_WRAPPER_BIN" --makepkg-capped "$@"

View File

@ -1,77 +0,0 @@
# Packages matching any of these substrings are blocked.
# Lines starting with # are comments.
firefox
librewolf
waterfox
icecat
floorp
zen-browser
tor-browser
torbrowser
mullvad-browser
basilisk
palemoon
iceweasel
abrowser
cliqz
freetube
seamonkey
min-browser
beaker-browser
catalyst-browser
hamsket
min
vieb
yt-dlp
stremio
angelfish
dooble
eric
falkon
fiery
maui
konqueror
liri
otter
quotebrowser
beaker
catalyst
badwolf
eolie
epiphany
surf
uzbl
vimb
web-browser
luakit
nyxt
tangram
dillo
links
netsurf
amfora
tartube
youtube
# Chrome/Chromium variants not handled by install_leechblock.sh
google-chrome
chromium
ungoogled-chromium
microsoft-edge
microsoft-edge-stable
microsoft-edge-beta
microsoft-edge-dev
iridium
slimjet
chromium-dev
# Firefox variants not handled by install_leechblock.sh
firefox-esr
firefox-nightly
# Opera variants not in BROWSERS array
opera-beta
opera-developer
# Qt/WebKit/other GUI browsers not handled by install_leechblock.sh
qutebrowser
midori
# VirtualBox (can bypass /etc/hosts restrictions)
virtualbox
vbox

View File

@ -1,3 +0,0 @@
# Packages matching any of these substrings require a challenge to install.
# They will also be uninstalled if found already installed.
# Lines starting with # are comments.

View File

@ -1,186 +0,0 @@
#!/bin/bash
# pacman_lock_lib.sh — shared stale pacman DB-lock handling.
#
# Single source of truth for detecting and clearing an orphaned
# /var/lib/pacman/db.lck. Sourced by BOTH pacman_wrapper.sh and
# makepkg_wrapper.sh so the stale-lock policy lives in exactly one place.
#
# This file only defines functions/vars — it must be *sourced*, never executed.
# It intentionally does NOT enable `set -euo pipefail`: the sourcing script owns
# its own shell options, and toggling them from a library would surprise callers.
#
# Testability: the lock path is taken from $PACMAN_LOCK_FILE (default
# /var/lib/pacman/db.lck) so the shell test harness can point it at a temp file
# instead of the real system lock. Production behaviour is unchanged when the
# variable is unset.
# Guard against double-sourcing (both wrappers may source transitively).
# This file is meant to be sourced, so `return` is the correct exit here.
if [[ -n ${_PACMAN_LOCK_LIB_LOADED:-} ]]; then
return 0
fi
_PACMAN_LOCK_LIB_LOADED=1
# Colors — only set if the caller has not already defined them, so a wrapper
# that defines its own palette keeps it. Values use literal escape sequences to
# match the callers' `echo -e` style.
: "${RED:=\033[0;31m}"
: "${GREEN:=\033[0;32m}"
: "${YELLOW:=\033[0;33m}"
: "${BLUE:=\033[0;34m}"
: "${CYAN:=\033[0;36m}"
: "${NC:=\033[0m}"
# Lock file location (overridable for tests).
PACMAN_LOCK_FILE="${PACMAN_LOCK_FILE:-/var/lib/pacman/db.lck}"
# Helper: detect if current invocation includes --noconfirm
has_noconfirm_flag() {
for arg in "$@"; do
if [[ $arg == "--noconfirm" ]]; then
return 0
fi
done
return 1
}
# Current unix epoch (bash builtin, no fork).
current_epoch() {
printf '%(%s)T\n' -1
}
# Helper: get list of PIDs holding a lock file (excluding our own PID)
# Populates the $holders array
get_lock_holders() {
local lock_file="$1"
holders=()
if command -v fuser >/dev/null 2>&1; then
read -r -a holders <<< "$(fuser "$lock_file" 2>/dev/null || true)"
elif command -v lsof >/dev/null 2>&1; then
mapfile -t holders < <(lsof -t "$lock_file" 2>/dev/null || true)
fi
# Filter out our own PID
if [[ ${#holders[@]} -gt 0 ]]; then
local -a filtered=()
for pid in "${holders[@]}"; do
[[ $pid -eq $$ ]] && continue
filtered+=("$pid")
done
holders=("${filtered[@]}")
fi
}
# Helper: is any real pacman/pamac transaction process running system-wide?
# Unlike fuser on the lock file, pgrep reads /proc/*/comm which is visible across
# users, so an UNPRIVILEGED caller (e.g. this lib running under makepkg, which is
# never root) can still detect a ROOT `pacman -Syu`. This closes the gap where a
# non-root fuser cannot see a root process's open fds and would wrongly conclude
# the lock is orphaned. Trade-off: a (rare) hung/zombie process literally named
# `pacman` will also block removal — erring toward safety over a rare re-hang.
pacman_process_running() {
pgrep -x pacman >/dev/null 2>&1 && return 0
pgrep -x pacman.orig >/dev/null 2>&1 && return 0
pgrep -x pamac >/dev/null 2>&1 && return 0
return 1
}
# Handle stale pacman database lock if present and no package managers are running.
# Returns 0 if the lock is absent, was cleared, or the caller may proceed.
# Returns 1 if a genuine pacman/pamac transaction (or an un-freeable updater)
# holds the lock, or the user declined to remove it — caller must NOT proceed.
check_and_handle_db_lock() {
local lock_file="$PACMAN_LOCK_FILE"
# Quick exit if no lock
if [[ ! -e $lock_file ]]; then
return 0
fi
# Determine which processes actually have the lock open
local -a holders=()
get_lock_holders "$lock_file"
if [[ ${#holders[@]} -gt 0 ]]; then
local pac_holder=0
local gui_holder=0
for pid in "${holders[@]}"; do
local comm args lower
comm=$(ps -p "$pid" -o comm= 2>/dev/null || true)
args=$(ps -p "$pid" -o args= 2>/dev/null || true)
lower="${comm,,} ${args,,}"
if [[ $lower == *" pacman"* || $lower == pacman* || $lower == *"/pacman "* || $lower == *" pamac"* ]]; then
pac_holder=1
elif [[ $lower == *packagekit* || $lower == *gnome-software* || $lower == *discover* ]]; then
gui_holder=1
fi
done
if [[ $pac_holder -eq 1 ]]; then
echo -e "${RED}Another pacman/pamac transaction is holding the database lock. Try again later.${NC}" >&2
return 1
fi
if [[ $gui_holder -eq 1 ]]; then
echo -e "${YELLOW}A background software updater is holding the pacman lock. Attempting to stop it...${NC}" >&2
if command -v systemctl >/dev/null 2>&1; then
systemctl --quiet stop packagekit.service 2>/dev/null || true
systemctl --quiet stop packagekit 2>/dev/null || true
fi
pkill -x packagekitd 2>/dev/null || true
pkill -f gnome-software 2>/dev/null || true
pkill -f discover 2>/dev/null || true
sleep 1
# Re-check holders
get_lock_holders "$lock_file"
if [[ ${#holders[@]} -gt 0 ]]; then
echo -e "${RED}Cannot free the pacman lock; another process still holds it. Try again later.${NC}" >&2
return 1
fi
fi
fi
# Cross-user safety net: even if fuser (possibly run unprivileged) could not
# see the holder, refuse to remove the lock while any real pacman/pamac
# process is running system-wide.
if pacman_process_running; then
echo -e "${RED}A pacman/pamac process is running; not removing the database lock. Try again later.${NC}" >&2
return 1
fi
# Helper to remove a file with sudo if needed
remove_file_as_root() {
local f="$1"
if [[ $EUID -ne 0 ]]; then
sudo rm -f "$f"
else
rm -f "$f"
fi
}
# Decide whether to remove the lock
local now epoch age
if epoch=$(stat -c %Y "$lock_file" 2>/dev/null); then
now=$(current_epoch)
age=$((now - epoch))
else
age=999999
fi
# Auto-remove in non-interactive mode (--noconfirm) or if the lock is older than 10 minutes
if has_noconfirm_flag "$@" || [[ $age -ge 600 ]]; then
echo -e "${YELLOW}Stale pacman lock detected (age: ${age}s). Removing it automatically...${NC}" >&2
remove_file_as_root "$lock_file" || return 1
return 0
fi
# Interactive prompt (15s timeout)
echo -e "${YELLOW}A pacman lock exists but no active pacman is running.${NC}" >&2
echo -e "${CYAN}Lock path:${NC} $lock_file (age: ${age}s)" >&2
read -r -t 15 -p $'Remove stale lock and continue? [y/N]: ' reply || reply="n"
if [[ ${reply,,} == "y" || ${reply,,} == "yes" ]]; then
remove_file_as_root "$lock_file" || return 1
return 0
fi
echo -e "${RED}Aborting due to existing pacman lock. Close other updaters and retry, or run with --noconfirm to auto-clear stale locks.${NC}" >&2
return 1
}

View File

@ -1,248 +0,0 @@
# Exact package names that should bypass the block even if matching a keyword.
thorium-browser-bin
# Allowed 2026-08-12: Thorium (thorium-browser-bin) has a recurring, deterministic
# internal crash (identical stack offsets across 7 coredumps, root cause an
# unresolved CHECK/assertion in a stripped binary at its latest available
# version — see session notes). Tried plain `chromium` (extra/chromium)
# first, but that package is already past Chromium's Manifest V2 removal
# (151.0.7922.108 — MV2 code paths gone as of Chromium 151, per upstream
# changelog), so full uBlock Origin (MV2) cannot load and it was removed.
# ungoogled-chromium is the V8/Chromium browser that still ships the MV2
# extension runtime as a maintainer-patched stopgap (NOT a permanent
# guarantee — could disappear in any future ungoogled-chromium release with
# no warning; re-check this if uBO stops loading after a pacman/yay update).
# The `chromium` KEYWORD stays blocked, so plain chromium/chromium-dev/etc.
# remain barred — only the exact `ungoogled-chromium` package name is allowed.
ungoogled-chromium
# Also allow the prebuilt -bin variant: a from-source ungoogled-chromium
# build via yay takes ~14h+ even throttled to half the machine's cores (full
# Chromium compile, 56000+ ninja steps) — completely impractical for same-day
# use. ungoogled-chromium-bin trails the source package by about one version
# (150.x vs 151.x measured 2026-08-12) since it's a separately-maintained
# prebuilt, but that lag is normal and acceptable; it is NOT a security
# downgrade rationale, just a build-time one.
ungoogled-chromium-bin
# makepkg.conf's global OPTIONS has `debug` enabled, so building
# ungoogled-chromium-bin also produces a companion
# ungoogled-chromium-bin-debug package (debug symbols only — not loaded or
# shipped by default). `yay -S` always installs both via one `pacman -U`
# call; whitelisting only the main package still blocks the whole
# transaction on the -debug package's name matching the `chromium` keyword.
ungoogled-chromium-bin-debug
# Allowed 2026-08-09 as a LeechBlock-enforced browser, same footing as Thorium:
# install_leechblock.sh already policy-installs the blocker into LibreWolf via
# /etc/librewolf/policies, so this is not an unfiltered escape hatch. Needed
# because Chromium's MV2 sunset ends full uBlock Origin (with all filter lists)
# on Thorium; the Gecko build keeps it. The `librewolf` KEYWORD stays blocked,
# so every other Firefox fork remains barred.
librewolf
minizip
miniupnpc
haskell-generically
haskell-streaming-commons
haskell-prettyprinter-ansi-terminal
haskell-generics-sop
haskell-ansi-terminal
minizip-ng
ruby-mini_portile2
texlive-plaingeneric
haskell-ansi-terminal-types
terminator
python
python-booleanoperations
python-brotli
python-defcon
python-fontmath
python-fontpens
python-fonttools
python-fs
python-lxml
python-tqdm
python-ufonormalizer
python-ufoprocessor
python-unicodedata2
python-zopfli
python-breaks
python-numpy
python-requests
python-pygmnets
python-chardet
python-dbus
python-distro
python breaks
python-geoip
python-idna
python-ifaddr
python-mako
python-pillow
python-pyopenssl
python-rencode
python-incremental
python-service-identity
python-setproctitle
python-setuptools
python-twisted
python-pyxdg
python-zope-interface
python-cairo
python-gobject
python-pygments
python-packaging
python-markdown
haskell-base64-bytestring
haskell-network-byte-order
haskell-byteorder
python-i3ipc
libbytesize
python-matplotlib
python-pyclipper
python-appdirs
python-six
python-click
python-markupsafe
python-cryptography
python-charset-normalizer
python-urllib3
python-attrs
python-pyasn1-modules
python-pyasn1
python-jaraco.collections
python-jaraco.functools
python-jaraco.text
python-more-itertools
python-wheel
python-attrs
python-automat
python-constantly
python-hyperlink
python-typing_extensions
python-mutatormath
python-fontparts
python-configobj
python-psutil
python-yaml
python-docopt
python-keyutils
python-jinja
python-opengl
haskell-base16-bytestring
python-cffi
python-xlib
python-jaraco.context
python-autocommand
python-contourpy
python-cycler
python-dateutil
python-kiwisolver
python-pyparsing
python-platformdirs
python-pycparser
python-dbus-next
python-parse
python-pyvips
python-systemd
python-colorlog
python-injector
python-peewee
python-py3nvml
python-reactivex
python-pyusb
python-hidapi
python-crcmod
gst-python
python-beautifulsoup4
python-certifi
python-evdev
python-moddb
python-aioquic
python-argon2-cffi
python-asgiref
python-flask
python-h11
python-h2
python-hyperframe
python-kaitaistruct
python-ldap3
python-mitmproxy-rs
python-msgpack
python-passlib
python-publicsuffix2
python-pyperclip
python-ruamel-yaml
python-sortedcontainers
python-tornado
python-urwid
python-wsproto
python-zstandard
python-bcrypt
python-aaf2
python-vdf
python-inputs
python-pyaml
python-steam
python-readme-renderer
python-requests-toolbelt
python-importlib-metadata
python-keyring
python-rfc3986
python-rich
python-id
python-pylsqpack
python-argon2-cffi-bindings
python-soupsieve
python-blinker
python-itsdangerous
python-werkzeug
python-hpack
python-zipp
python-jaraco.classes
python-secretstorage
python-pkgconfig
python-docutils
python-nh3
python-markdown-it-py
python-ruamel.yaml.clib
python-cachetools
python-pycryptodomex
python-wcwidth
python-mdurl
python-jeepney
python-bashate
python-discover
python-reno
python-autopage
python-babel
python-cliff
python-cmd2
python-clorama
python-fixtures
python-imagesize
python-iso8601
python-prettytable
python-pyperclip
python-pyproject-hooks
python-pytz
python-roman-numerals-py
python-snowballstemmer
python-sphinx-alabaster-theme
python-sphinxcontrib-applehelp
python-sphinxcontrib-devhelp
python-sphinxcontrib-htmlhelp
python-sphinxcontrib-jsmath
python-sphinxcontrib-qthelp
python-sphinxcontrib-serializinghtml
python-stevedore
python-subunit
python-tomlkit
python-voluptuous
python-wcwidth
python-build
python-docutils
python-dulwich
python-installer
python-pbr
python-sphinx
python-stestr
python-testscenarios
python-testtools
python-entrypoints

View File

@ -1,215 +0,0 @@
#!/bin/bash
# filepath: pacman-wrapper.sh
# A helpful wrapper for Arch Linux's pacman package manager
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
# Exported: consumed by pw_ui.sh and pw_cleanup.sh, which are sourced
# separately, so shellcheck cannot see the use without -x.
export BOLD='\033[1m'
NC='\033[0m' # No Color
PACMAN_BIN="/usr/bin/pacman"
# ---------------------------------------------------------------------------
# Libraries. Sourced HERE, at the very top, because the fast path below execs
# real pacman and needs_unlock() — which decides whether to take it — now lives
# in pw_guard.sh. Deployed FLAT beside this script in /usr/local/bin, the same
# shape as pacman_lock_lib.sh and heavy_job_lock.sh; in the repo they sit
# beside it too, so one resolution rule covers both.
#
# FAIL OPEN, deliberately, and unlike every other split in this campaign: this
# file IS /usr/bin/pacman. If a lib goes missing, aborting would leave the
# machine with no working package manager and no way to reinstall the lib. So
# a missing lib degrades to plain pacman with a warning on stderr, which is
# the same choice the existing pacman_lock_lib.sh block below already makes.
#
# The integrity check that guards the POLICY files still runs below, before
# any transaction is allowed through.
# ---------------------------------------------------------------------------
_WRAPPER_DIR="$(dirname "$(readlink -f "$0")")"
for _pw_lib in pw_policy pw_guard pw_ui pw_challenge pw_cleanup; do
if [[ -r "$_WRAPPER_DIR/${_pw_lib}.sh" ]]; then
# shellcheck source=/dev/null
source "$_WRAPPER_DIR/${_pw_lib}.sh"
fi
done
unset _pw_lib
if ! declare -F needs_unlock >/dev/null || ! declare -F verify_policy_integrity >/dev/null; then
printf 'Warning: pacman wrapper libraries missing; running pacman unwrapped.\n' >&2
exec "$PACMAN_BIN" "$@"
fi
# Installed alongside the wrapper by install_pacman_wrapper.sh. Absent on a
# machine that has not run it yet, in which case transactions simply are not
# serialised (see the guarded source below).
HEAVY_JOB_LOCK_LIB="/usr/local/bin/heavy_job_lock.sh"
# Exported: used only by pw_ui.sh (sourced separately).
export MAKEPKG_CAPPED_BIN="/usr/local/bin/makepkg_capped"
# The policy lists and the integrity-file paths are declared in pw_policy.sh,
# the only file that reads or writes them.
# Stale pacman DB-lock detection/handling (get_lock_holders,
# check_and_handle_db_lock) plus has_noconfirm_flag and current_epoch now live in
# the shared pacman_lock_lib.sh, sourced after the integrity check below. This is
# the single source of truth shared with makepkg_wrapper.sh.
# Check for wrapper-specific commands
if [[ $1 == "--help-wrapper" ]]; then
show_help
exit 0
fi
if [[ ${1:-} == "--makepkg-capped" ]]; then
shift
run_makepkg_capped "$@"
fi
# ---------------------------------------------------------------------------
# Fast pass-through for unprivileged, sandboxed and read-only invocations.
#
# makepkg/yay invoke pacman dozens of times for dependency resolution and
# metadata (e.g. `pacman -T`, `-Qi`, `-Qq`) — as a non-root user and inside a
# fakeroot build sandbox. Policy enforcement, service checks and package
# cleanup only make sense for a genuine privileged transaction (root running
# -S/-U/-R/-Syu ...), so for everything else we exec the real pacman directly.
# This avoids the root-only policy-file read ("policy.sha256: Permission
# denied"), the D-Bus "Failed to connect to system scope bus" errors from
# systemctl inside the build sandbox, and the per-call log spam during builds.
#
# Note: inside fakeroot $EUID reports 0 (libfakeroot intercepts geteuid), so it
# is the FAKEROOTKEY check — not the EUID check — that catches in-sandbox calls.
# ---------------------------------------------------------------------------
if [[ $EUID -ne 0 || -n ${FAKEROOTKEY:-} ]] || ! needs_unlock "$@"; then
exec "$PACMAN_BIN" "$@"
fi
# CRITICAL: Verify policy file integrity before any operations
if ! verify_policy_integrity; then
exit 1
fi
# Load shared stale-lock helpers (get_lock_holders, check_and_handle_db_lock,
# has_noconfirm_flag, current_epoch), AFTER integrity verification so a tampered
# lib (listed in the integrity manifest) is caught before it is sourced. Sourced
# here — past the fast-path — so build-time non-root calls skip it entirely.
if [[ -r "$_WRAPPER_DIR/pacman_lock_lib.sh" ]]; then
# shellcheck source=pacman_lock_lib.sh
source "$_WRAPPER_DIR/pacman_lock_lib.sh"
else
# Fail open: a missing lib must not brick pacman (you'd need pacman to
# reinstall it). Skip stale-lock handling with safe no-op fallbacks.
echo -e "${YELLOW}Warning: pacman_lock_lib.sh missing; stale-lock handling disabled.${NC}" >&2
check_and_handle_db_lock() { return 0; }
current_epoch() { printf '%(%s)T\n' -1; }
fi
# Before any pacman action, ensure maintenance services exist
ensure_periodic_maintenance
# PROACTIVE CLEANUP: Always check and remove blocked packages at startup
# This catches packages that were installed before the wrapper or via other means
echo -e "${CYAN}Checking for blocked packages...${NC}" >&2
remove_installed_blocked_packages "$@"
remove_installed_greylisted_packages "$@"
# Check for always blocked packages first (highest priority)
if check_for_always_blocked "$@"; then
echo -e "${RED}Installation BLOCKED: This package is permanently restricted and cannot be installed.${NC}"
echo -e "${RED}Package installation has been denied by system policy.${NC}"
# Regardless of the attempted action, enforce cleanup of any installed blocked packages
remove_installed_blocked_packages "$@"
exit 1
fi
# Check for steam (challenge-eligible package)
if check_for_steam "$@"; then
if ! prompt_for_steam_challenge; then
exit 1
fi
fi
# Check for greylisted packages (challenge-eligible)
if check_for_greylisted "$@"; then
if ! prompt_for_greylist_challenge; then
exit 1
fi
fi
# Display operation
display_operation "$1"
# Echo the command that's about to be executed
echo -e "${GREEN}Executing:${NC} $PACMAN_BIN $*" >&2
# Record start time for statistics
start_time=$(current_epoch)
# Handle a possible stale DB lock before executing
if ! check_and_handle_db_lock "$@"; then
exit 1
fi
manual_guard_lib_fallback=0
# Execute the real pacman command (with guard-lib fallback handling)
if should_use_wrapper_guard_lib_fallback "$@"; then
pre_unlock_guard_lib
manual_guard_lib_fallback=1
fi
# Serialise real TRANSACTIONS against builds and coverage runs on this 7.6 GB
# box (see utils/heavy_job_lock.sh). Deliberately scoped by needs_unlock, i.e.
# -S/-U/-R only: read-only queries like `pacman -Q` take no db lock and run
# constantly from background services here, so making those wait behind a
# 20-minute Gradle build would be worse than the contention it avoids.
# with_heavy_lock always fails open, so this can never brick package management.
if [[ -r $HEAVY_JOB_LOCK_LIB ]] && needs_unlock "$@"; then
# shellcheck source=/dev/null
source "$HEAVY_JOB_LOCK_LIB"
with_heavy_lock pacman -- "$PACMAN_BIN" "$@"
else
"$PACMAN_BIN" "$@"
fi
exit_code=$?
if [[ $manual_guard_lib_fallback -eq 1 ]]; then
post_relock_guard_lib
fi
# Record end time for statistics
end_time=$(current_epoch)
duration=$((end_time - start_time))
# Display results
if [ $exit_code -eq 0 ]; then
echo -e "${GREEN}Command completed successfully in ${duration}s.${NC}" >&2
else
echo -e "${RED}Command failed with exit code ${exit_code}.${NC}" >&2
fi
# After any operation, remove installed blocked packages as part of policy enforcement
remove_installed_blocked_packages "$@"
# Also remove installed greylisted packages
remove_installed_greylisted_packages "$@"
auto_install_leechblock "$@"
auto_remove_virtualbox_vms
# Display some helpful tips depending on the operation
if [[ $1 == "-S" || $1 == "-S "* ]] && [ $exit_code -eq 0 ]; then
echo -e "${CYAN}Tip:${NC} You may need to log out or restart to use some newly installed software."
fi
if [[ $1 == "-Syu" || $1 == "-Syyu" ]] && [ $exit_code -eq 0 ]; then
echo -e "${CYAN}Tip:${NC} Consider restarting after major updates."
fi
exit $exit_code

View File

@ -1,177 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
current_day_of_week() {
printf '%(%u)T\n' -1
}
current_hour_24() {
printf '%(%H)T\n' -1
}
current_day_name() {
printf '%(%A)T\n' -1
}
# Function to check if current day is a weekday (after 4PM Friday until midnight Sunday)
function is_weekday() {
local day_of_week
day_of_week=$(current_day_of_week) # %u gives 1-7 (Monday is 1, Sunday is 7)
local hour
hour=$(current_hour_24) # %H gives hour in 24-hour format (00-23)
# Monday through Thursday are always weekdays
if [[ $day_of_week -ge 1 && $day_of_week -le 4 ]]; then
return 0 # Is weekday
# Friday before 4PM is weekday, after 4PM is weekend
elif [[ $day_of_week -eq 5 ]]; then
if [[ $hour -lt 14 ]]; then
return 0 # Is weekday (Friday before 4PM)
else
return 1 # Is weekend (Friday after 4PM)
fi
# Saturday and Sunday are weekend
else
return 1 # Is weekend
fi
}
# Unified word unscrambling challenge function
# Args: challenge_name word_length words_count timeout_seconds initial_delay_max post_delay_min post_delay_range
function run_word_challenge() {
local challenge_name="$1"
local word_length="$2"
local words_count="$3"
local timeout_seconds="$4"
local initial_delay_max="${5:-20}"
local post_delay_min="${6:-0}"
local post_delay_range="${7:-20}"
echo -e "${YELLOW}${challenge_name} challenge will begin shortly...${NC}"
# Initial delay
local sleep_duration=$((RANDOM % initial_delay_max))
sleep "$sleep_duration"
# Load words file
local script_dir words_file
script_dir="$(dirname "$(readlink -f "$0")")"
words_file="$script_dir/words.txt"
if [[ ! -f $words_file ]]; then
echo -e "${RED}Error: words.txt file not found at $words_file${NC}"
return 1
fi
echo -e "${CYAN}Challenge: Words with ${word_length} letters${NC}"
# Load random words of specified length
local -a selected_words
mapfile -t selected_words < <(grep -E "^[a-zA-Z]{$word_length}$" "$words_file" | shuf -n "$words_count")
if [[ ${#selected_words[@]} -lt $words_count ]]; then
echo -e "${RED}Warning: Could only find ${#selected_words[@]} words of length $word_length.${NC}"
words_count=${#selected_words[@]}
if [[ $words_count -eq 0 ]]; then
echo -e "${RED}Error: No words of length $word_length found in $words_file${NC}"
return 1
fi
fi
# Convert to uppercase
for i in "${!selected_words[@]}"; do
selected_words[i]=$(echo "${selected_words[i]}" | tr '[:lower:]' '[:upper:]')
done
echo -e "${CYAN}Here are ${words_count} random words. Remember them:${NC}"
# Display words in grid
for ((i = 0; i < words_count; i++)); do
printf "${BLUE}%-15s${NC}" "${selected_words[i]}"
if (((i + 1) % 4 == 0)); then
echo ""
fi
done
# Select and scramble a word
local target_index target_word scrambled_word
target_index=$((RANDOM % words_count))
target_word="${selected_words[target_index]}"
scrambled_word=$(echo "$target_word" | fold -w1 | shuf | tr -d '\n')
if [[ $scrambled_word == "$target_word" ]]; then
scrambled_word=$(echo "$target_word" | rev)
fi
echo -e "\n${YELLOW}One of those words has been scrambled to:${NC} ${CYAN}$scrambled_word${NC}"
echo -e "${YELLOW}Unscramble the word to proceed (you have $timeout_seconds seconds):${NC}"
# Timer display background process
(
local start_time current_time elapsed remaining
start_time=$(current_epoch)
while true; do
current_time=$(current_epoch)
elapsed=$((current_time - start_time))
remaining=$((timeout_seconds - elapsed))
if [[ $remaining -le 0 ]]; then
echo -ne "\r${YELLOW}Time remaining: 0 seconds${NC} "
break
fi
echo -ne "\r${YELLOW}Time remaining: ${remaining} seconds${NC} "
sleep 1
done
) &
local display_pid=$!
# Read input with timeout
local user_input read_status
read -t "$timeout_seconds" -r user_input
read_status=$?
kill "$display_pid" 2>/dev/null
wait "$display_pid" 2>/dev/null
echo
if [[ $read_status -ne 0 ]]; then
echo -e "${RED}Time's up! Challenge failed. The correct word was '$target_word'.${NC}"
return 1
fi
user_input=$(echo "$user_input" | tr '[:lower:]' '[:upper:]' | xargs)
if [[ $user_input == "$target_word" ]]; then
echo -e "${GREEN}Correct! Proceeding with installation...${NC}"
local post_challenge_sleep=$((RANDOM % post_delay_range + post_delay_min))
[[ $post_challenge_sleep -gt 0 ]] && sleep "$post_challenge_sleep"
return 0
else
echo -e "${RED}Incorrect answer. Installation aborted. The correct word was '$target_word'.${NC}"
return 1
fi
}
# Function to prompt for solving a word unscrambling challenge (only for steam)
function prompt_for_steam_challenge() {
echo -e "${YELLOW}WARNING: You are trying to install Steam.${NC}"
# Check if it's a weekday and block completely
if is_weekday; then
local day_name
day_name=$(current_day_name)
echo -e "${RED}Steam installation BLOCKED: Steam cannot be installed on weekdays.${NC}"
echo -e "${RED}Today is $day_name. Please try again on the weekend (Saturday or Sunday).${NC}"
return 1
fi
# word_length=5, words_count=160, timeout=60s, initial_delay=20, post_delay=0-20
run_word_challenge "Weekend Steam" 5 160 60 20 0 20
}
# Function to prompt for solving a word unscrambling challenge (for greylisted packages - always active)
function prompt_for_greylist_challenge() {
echo -e "${YELLOW}WARNING: You are trying to install a greylisted package.${NC}"
# word_length=6, words_count=120, timeout=90s, initial_delay=30, post_delay=15-35
run_word_challenge "Greylist" 6 120 90 30 15 20
}

View File

@ -1,153 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
# Generic function to remove installed packages matching a filter
# Args: check_function label_prefix
function remove_installed_packages_matching() {
local check_function="$1"
local label="$2"
mapfile -t installed_names < <("$PACMAN_BIN" -Qq 2>/dev/null)
local to_remove=()
for name in "${installed_names[@]}"; do
if "$check_function" "$name"; then
to_remove+=("$name")
fi
done
if [[ ${#to_remove[@]} -eq 0 ]]; then
return 0
fi
echo -e "${YELLOW}${label} cleanup:${NC} Removing packages: ${BOLD}${to_remove[*]}${NC}" >&2
"$PACMAN_BIN" -Rns --noconfirm "${to_remove[@]}"
local rc=$?
if [[ $rc -ne 0 ]]; then
echo -e "${RED}${label} cleanup removal failed with exit code ${rc}.${NC}" >&2
else
echo -e "${GREEN}${label} cleanup removal completed for: ${to_remove[*]}${NC}" >&2
fi
return $rc
}
# Cleanup: remove any installed blocked packages
function remove_installed_blocked_packages() {
remove_installed_packages_matching is_blocked_package_name "Policy"
}
# Cleanup: remove any installed greylisted packages
function remove_installed_greylisted_packages() {
remove_installed_packages_matching is_greylisted_package_name "Greylist"
}
# Auto-install LeechBlock if a browser is detected
auto_install_leechblock() {
# Only check after install operations
if [[ -z ${1:-} ]] || [[ $1 != "-S"* && $1 != "-U"* ]]; then
return 0
fi
# List of browser packages to check for
local browsers=("firefox" "librewolf" "chromium" "brave" "vivaldi" "google-chrome" "ungoogled-chromium")
local browser_found=0
for browser in "${browsers[@]}"; do
if "$PACMAN_BIN" -Qq "$browser" 2>/dev/null; then
browser_found=1
break
fi
done
if [[ $browser_found -eq 0 ]]; then
return 0
fi
# Find the LeechBlock installer
local script_dir
script_dir="$(dirname "$(readlink -f "$0")")"
local leechblock_installer=""
if [[ -f "/usr/local/share/digital_wellbeing/install_leechblock.sh" ]]; then
leechblock_installer="/usr/local/share/digital_wellbeing/install_leechblock.sh"
elif [[ -f "$script_dir/../install_leechblock.sh" ]]; then
leechblock_installer="$script_dir/../install_leechblock.sh"
fi
if [[ -z $leechblock_installer ]]; then
echo -e "${YELLOW}Browser detected but LeechBlock installer not found.${NC}" >&2
return 0
fi
# Check if LeechBlock is already installed (by looking for the extension directory)
if [[ -d "$HOME/.local/share/leechblockng" ]]; then
return 0
fi
echo -e "${CYAN}Browser detected. Installing LeechBlock extension for website blocking...${NC}" >&2
# Run the LeechBlock installer (as current user, not root)
if [[ $EUID -eq 0 && -n "${SUDO_USER:-}" ]]; then
sudo -u "$SUDO_USER" bash "$leechblock_installer" --install-firefox 2>&1 || {
echo -e "${YELLOW}LeechBlock auto-install failed. Please install manually:${NC}" >&2
echo -e "${YELLOW} $leechblock_installer${NC}" >&2
}
else
bash "$leechblock_installer" --install-firefox 2>&1 || {
echo -e "${YELLOW}LeechBlock auto-install failed. Please install manually:${NC}" >&2
echo -e "${YELLOW} $leechblock_installer${NC}" >&2
}
fi
}
# If VirtualBox is installed, automatically remove all VMs
auto_remove_virtualbox_vms() {
# Check if VBoxManage is available
if ! command -v VBoxManage &>/dev/null; then
return 0
fi
# Determine real user (wrapper may run as root via sudo)
local real_user="${SUDO_USER:-$USER}"
# Get list of registered VMs (run as real user since VMs are per-user)
local vm_list
vm_list=$(sudo -u "$real_user" VBoxManage list vms 2>/dev/null) || return 0
if [[ -z $vm_list ]]; then
return 0
fi
echo -e "${RED}═══════════════════════════════════════════════════════${NC}" >&2
echo -e "${RED} VIRTUALBOX VMs DETECTED - AUTO-REMOVING ${NC}" >&2
echo -e "${RED}═══════════════════════════════════════════════════════${NC}" >&2
local vm_name
local success=0
local failed=0
while IFS= read -r line; do
# VBoxManage list vms output format: "VM Name" {uuid}
vm_name="${line#\"}"
vm_name="${vm_name%%\"*}"
if [[ -z $vm_name ]]; then
continue
fi
echo -e "${YELLOW}Removing VM: ${vm_name}${NC}" >&2
# Power off the VM if it's running
sudo -u "$real_user" VBoxManage controlvm "$vm_name" poweroff 2>/dev/null || true
sleep 1
# Unregister and delete all files
if sudo -u "$real_user" VBoxManage unregistervm "$vm_name" --delete 2>/dev/null; then
echo -e "${GREEN} Removed: ${vm_name}${NC}" >&2
((++success))
else
echo -e "${RED} Failed to remove: ${vm_name}${NC}" >&2
((++failed))
fi
done <<<"$vm_list"
echo -e "${CYAN}VM removal complete: ${success} removed, ${failed} failed.${NC}" >&2
}

View File

@ -1,145 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
# Determine if this invocation may perform a transaction (upgrade/install/remove)
needs_unlock() {
# If args include -S (install/upgrade), -U (local install), or -R (remove), we unlock
# Also include -Su/-Syu/-Syuu when -S is part of the combined flag
for arg in "$@"; do
case "$arg" in
-S*)
return 0
;;
-U)
return 0
;;
-R)
return 0
;;
--sync)
return 0
;;
--upgrade)
return 0
;;
--remove)
return 0
;;
esac
done
return 1
}
pacman_hooks_manage_guard_lib() {
local pre_hook="/etc/pacman.d/hooks/10-guard-lib-unlock-all.hook"
local post_hook="/etc/pacman.d/hooks/90-guard-lib-relock-all.hook"
local pre_exec="/etc/guard-lib/pacman-hooks/guard-lib-unlock-all.sh"
local post_exec="/etc/guard-lib/pacman-hooks/guard-lib-relock-all.sh"
if [[ ! -f $pre_hook || ! -f $post_hook ]]; then
return 1
fi
grep -Fq "$pre_exec" "$pre_hook" && grep -Fq "$post_exec" "$post_hook"
}
should_use_wrapper_guard_lib_fallback() {
if ! needs_unlock "$@"; then
return 1
fi
if pacman_hooks_manage_guard_lib; then
return 1
fi
return 0
}
# Run guard-lib's own generic unlock-all/relock-all scripts directly if
# pacman's own hooks for them are missing (e.g. hooks disabled/misconfigured).
# These cover every registered file-guard instance (hosts, nsswitch,
# resolved, shutdown-schedule, ...), not just /etc/hosts.
pre_unlock_guard_lib() {
local pre="/etc/guard-lib/pacman-hooks/guard-lib-unlock-all.sh"
if [[ -x $pre ]]; then
echo -e "${CYAN}[guard-lib] Preparing guarded files for transaction...${NC}" >&2
/bin/bash "$pre" || true
fi
}
post_relock_guard_lib() {
local post="/etc/guard-lib/pacman-hooks/guard-lib-relock-all.sh"
if [[ -x $post ]]; then
/bin/bash "$post" || true
echo -e "${CYAN}[guard-lib] Protections re-applied to guarded files.${NC}" >&2
fi
}
# Ensure periodic system services (timer/monitor) are set up; if not, trigger setup
ensure_periodic_maintenance() {
# Only proceed if systemd/systemctl is available
if ! command -v systemctl >/dev/null 2>&1; then
return 0
fi
local timer_unit="periodic-system-maintenance.timer"
local startup_unit="periodic-system-startup.service"
local monitor_unit="hosts-file-monitor.service"
local needs_setup=0
# Timer should be enabled and active
systemctl --quiet is-enabled "$timer_unit" || needs_setup=1
systemctl --quiet is-active "$timer_unit" || needs_setup=1
# Monitor should be enabled and active
systemctl --quiet is-enabled "$monitor_unit" || needs_setup=1
systemctl --quiet is-active "$monitor_unit" || needs_setup=1
# Startup service should be enabled (it’s oneshot and may not be active except at boot)
systemctl --quiet is-enabled "$startup_unit" || needs_setup=1
if [[ $needs_setup -eq 0 ]]; then
return 0
fi
echo -e "${YELLOW}Periodic maintenance services missing or inactive. Running setup...${NC}" >&2
# Try to locate setup_periodic_system.sh. The installed wrapper lives in
# /usr/local/bin (so $self_dir won't contain it) and, for real transactions,
# runs as root under sudo (so $HOME points at /root). Resolve the invoking
# user's home via SUDO_USER and probe the known repo locations.
local setup_script=""
local self_dir real_user real_home
self_dir="$(dirname "$(readlink -f "$0")")"
real_user="${SUDO_USER:-${USER:-$(id -un)}}"
real_home="$(getent passwd "$real_user" 2>/dev/null | cut -d: -f6)"
[[ -z $real_home ]] && real_home="$HOME"
local -a setup_candidates=(
"$self_dir/setup_periodic_system.sh"
"$real_home/testsAndMisc/linux_configuration/periodic_background/setup_periodic_system.sh"
"$real_home/linux_configuration/periodic_background/setup_periodic_system.sh"
"$real_home/linux-configuration/periodic_background/setup_periodic_system.sh"
)
local candidate
for candidate in "${setup_candidates[@]}"; do
if [[ -f $candidate ]]; then
setup_script="$candidate"
break
fi
done
if [[ -n $setup_script ]]; then
if [[ $EUID -ne 0 ]]; then
sudo bash "$setup_script"
else
bash "$setup_script"
fi
echo -e "${CYAN}Tip:${NC} To disable these later:" >&2
echo " sudo systemctl disable periodic-system-maintenance.timer" >&2
echo " sudo systemctl disable periodic-system-startup.service" >&2
echo " sudo systemctl disable hosts-file-monitor.service" >&2
else
echo -e "${RED}Could not locate setup_periodic_system.sh to configure services automatically.${NC}" >&2
fi
}

View File

@ -1,198 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
# Declared here rather than in the entry script: this is the only file that
# reads or writes them, and a cross-file global the sourcing script cannot
# see is a genuine SC2034 (the hook runs shellcheck without -x).
declare -a BLOCKED_KEYWORDS_LIST=()
declare -a WHITELISTED_NAMES_LIST=()
declare -a GREYLISTED_KEYWORDS_LIST=()
POLICY_LISTS_LOADED=0
INTEGRITY_DIR="/var/lib/pacman-wrapper"
INTEGRITY_FILE="${INTEGRITY_DIR}/policy.sha256"
# Verify integrity of policy files
verify_policy_integrity() {
if [[ ! -f $INTEGRITY_FILE ]]; then
echo -e "${RED}SECURITY WARNING: Policy integrity file missing!${NC}" >&2
echo -e "${RED}The pacman wrapper may have been tampered with.${NC}" >&2
echo -e "${RED}Please reinstall the wrapper using: sudo install_pacman_wrapper.sh${NC}" >&2
return 1
fi
local script_dir
script_dir="$(dirname "$(readlink -f "$0")")"
local blocked_file="$script_dir/pacman_blocked_keywords.txt"
local greylist_file="$script_dir/pacman_greylist.txt"
local whitelist_file="$script_dir/pacman_whitelist.txt"
# Verify checksums
local failed=0
while IFS= read -r line; do
local expected_hash expected_file
expected_hash=$(echo "$line" | awk '{print $1}')
expected_file=$(echo "$line" | awk '{print $2}')
if [[ -f $expected_file ]]; then
local actual_hash
actual_hash=$(sha256sum "$expected_file" 2>/dev/null | awk '{print $1}')
if [[ $actual_hash != "$expected_hash" ]]; then
echo -e "${RED}SECURITY WARNING: Policy file integrity check failed for $expected_file${NC}" >&2
failed=1
fi
fi
done <"$INTEGRITY_FILE"
if [[ $failed -eq 1 ]]; then
echo -e "${RED}CRITICAL: Policy files have been tampered with!${NC}" >&2
echo -e "${RED}This could be an attempt to bypass security restrictions.${NC}" >&2
echo -e "${RED}Wrapper operation DENIED. Please reinstall using: sudo install_pacman_wrapper.sh${NC}" >&2
return 1
fi
return 0
}
# shellcheck disable=SC2329 # invoked indirectly, see is_blocked_package_name/is_greylisted_package_name callers below
load_policy_lists() {
if [[ $POLICY_LISTS_LOADED -eq 1 ]]; then
return
fi
local script_dir
script_dir="$(dirname "$(readlink -f "$0")")"
local blocked_file="$script_dir/pacman_blocked_keywords.txt"
local whitelist_file="$script_dir/pacman_whitelist.txt"
local greylist_file="$script_dir/pacman_greylist.txt"
read_policy_list_file() {
local file_path="$1"
local -n target_array="$2"
local line=""
target_array=()
while IFS= read -r line || [[ -n $line ]]; do
line="${line%$'\r'}"
if [[ $line =~ ^[[:space:]]*(#|$) ]]; then
continue
fi
target_array+=("${line,,}")
done <"$file_path"
}
if [[ -f $blocked_file ]]; then
read_policy_list_file "$blocked_file" BLOCKED_KEYWORDS_LIST
else
BLOCKED_KEYWORDS_LIST=()
echo -e "${YELLOW}Warning:${NC} Missing blocked keywords file at $blocked_file" >&2
fi
if [[ -f $whitelist_file ]]; then
read_policy_list_file "$whitelist_file" WHITELISTED_NAMES_LIST
else
WHITELISTED_NAMES_LIST=()
fi
if [[ -f $greylist_file ]]; then
read_policy_list_file "$greylist_file" GREYLISTED_KEYWORDS_LIST
else
GREYLISTED_KEYWORDS_LIST=()
fi
POLICY_LISTS_LOADED=1
}
# Helper: strip a pacman package-file suffix (-<pkgver>-<pkgrel>-<arch>.pkg.tar.<ext>)
# down to the bare package name, if present. `pacman -U <path>` (what `yay`
# always uses to install a package it just built locally) passes a filename
# like "ungoogled-chromium-bin-150.0.7871.186-1-x86_64.pkg.tar.zst", not the
# bare name — without this, an exact-match whitelist entry for
# "ungoogled-chromium-bin" could never match a `-U` install and would always
# fall through to the (correctly still-substring) blocked-keywords check.
# Leaves package names without this suffix (the `-S` case) untouched.
# shellcheck disable=SC2329 # invoked indirectly by name (is_blocked_package_name)
function strip_pkgfile_suffix() {
local name="$1"
if [[ $name =~ ^(.+)-[^-]+-[0-9]+-(x86_64|any|i686|aarch64)\.pkg\.tar\.(zst|xz|gz|bz2|lrz|lzo|Z)$ ]]; then
printf '%s' "${BASH_REMATCH[1]}"
else
printf '%s' "$name"
fi
}
# Helper: return 0 if the given package name is blocked by policy
# shellcheck disable=SC2329 # invoked indirectly by name (remove_installed_packages_matching, check_install_for)
function is_blocked_package_name() {
load_policy_lists
local normalized="${1,,}"
local bare_name
bare_name="$(strip_pkgfile_suffix "$normalized")"
for allowed in "${WHITELISTED_NAMES_LIST[@]}"; do
if [[ $normalized == "$allowed" || $bare_name == "$allowed" ]]; then
return 1
fi
done
for keyword in "${BLOCKED_KEYWORDS_LIST[@]}"; do
if [[ $normalized == *"$keyword"* ]]; then
return 0
fi
done
return 1
}
# Helper: return 0 if the given package name is greylisted (challenge required)
# shellcheck disable=SC2329 # invoked indirectly by name (remove_installed_packages_matching, check_install_for)
function is_greylisted_package_name() {
load_policy_lists
local normalized="${1,,}"
for keyword in "${GREYLISTED_KEYWORDS_LIST[@]}"; do
if [[ $normalized == *"$keyword"* ]]; then
return 0
fi
done
return 1
}
# Helper: Check if this is an install command and run a filter on each package name
# Usage: check_install_for filter_func "$@"
# Returns 0 if filter_func matches any package
function check_install_for() {
local filter_func="$1"
shift
# Check if the command is an installation command
if [[ ${1:-} == "-S" || ${1:-} == "-Sy" || ${1:-} == "-Syu" || ${1:-} == "-Syyu" || ${1:-} == "-U" ]]; then
for arg in "$@"; do
# Strip repository prefix if present (like extra/ or community/)
local package_name="${arg##*/}"
if "$filter_func" "$package_name"; then
return 0
fi
done
fi
return 1
}
# Function to check if user is trying to install packages that are always blocked
function check_for_always_blocked() {
check_install_for is_blocked_package_name "$@"
}
# Helper to check if a package name is steam
# shellcheck disable=SC2329 # invoked indirectly by name (check_install_for)
function is_steam_package() {
[[ $1 == "steam" ]]
}
# Function to check if user is trying to install steam (challenge-eligible package)
function check_for_steam() {
check_install_for is_steam_package "$@"
}
function check_for_greylisted() {
check_install_for is_greylisted_package_name "$@"
}

View File

@ -1,90 +0,0 @@
#!/usr/bin/env bash
# Helpers sourced by the entry script.
# Function to display help
function show_help() {
echo -e "${BOLD}Pacman Wrapper Help${NC}"
echo "This wrapper adds helpful features while preserving all pacman functionality."
echo ""
echo "Additional commands:"
echo " --help-wrapper Show this help message"
echo " --makepkg-capped Run makepkg in a constrained systemd user scope"
echo " (forward remaining args to makepkg)"
}
run_makepkg_capped() {
if [[ ! -x $MAKEPKG_CAPPED_BIN ]]; then
echo -e "${RED}makepkg capped wrapper not found at ${MAKEPKG_CAPPED_BIN}${NC}" >&2
echo -e "${YELLOW}Run install_pacman_wrapper.sh to install it.${NC}" >&2
return 1
fi
if [[ $EUID -eq 0 && -n ${SUDO_USER:-} ]]; then
exec sudo -u "$SUDO_USER" "$MAKEPKG_CAPPED_BIN" "$@"
fi
exec "$MAKEPKG_CAPPED_BIN" "$@"
}
# Function to display a message before executing
function display_operation() {
case "$1" in
-S)
echo -e "${BLUE}Installing packages...${NC}" >&2
;;
-Sy)
echo -e "${BLUE}Installing packages...${NC}" >&2
;;
-S\ *)
echo -e "${BLUE}Installing packages...${NC}" >&2
;;
-Syu | -Syyu)
echo -e "${BLUE}Updating system...${NC}" >&2
;;
-R)
echo -e "${YELLOW}Removing packages...${NC}" >&2
;;
-Rs)
echo -e "${YELLOW}Removing packages...${NC}" >&2
;;
-Rns)
echo -e "${YELLOW}Removing packages...${NC}" >&2
;;
-R\ *)
echo -e "${YELLOW}Removing packages...${NC}" >&2
;;
-Ss)
echo -e "${CYAN}Searching for packages...${NC}" >&2
;;
-Ss\ *)
echo -e "${CYAN}Searching for packages...${NC}" >&2
;;
-Q)
echo -e "${CYAN}Querying package database...${NC}" >&2
;;
-Qs)
echo -e "${CYAN}Querying package database...${NC}" >&2
;;
-Qi)
echo -e "${CYAN}Querying package database...${NC}" >&2
;;
-Ql)
echo -e "${CYAN}Querying package database...${NC}" >&2
;;
-Q\ *)
echo -e "${CYAN}Querying package database...${NC}" >&2
;;
-U)
echo -e "${BLUE}Installing local packages...${NC}" >&2
;;
-U\ *)
echo -e "${BLUE}Installing local packages...${NC}" >&2
;;
-Scc)
echo -e "${YELLOW}Cleaning package cache...${NC}" >&2
;;
*)
echo -e "${CYAN}Executing pacman command...${NC}" >&2
;;
esac
}

View File

@ -1,34 +0,0 @@
#!/bin/bash
# rewrap_pkg_managers.sh — restore the pacman + makepkg wrapper symlinks.
#
# A `pacman-git` upgrade reinstalls /usr/bin/pacman and /usr/bin/makepkg as the
# stock binaries, clobbering our wrapper symlinks. This helper re-establishes
# them (refreshing the .orig backups from the freshly installed binaries) so the
# wrappers survive upgrades.
#
# It is invoked by the PostTransaction pacman hook (96-restore-pkg-wrappers.hook)
# and by the periodic drift verifier. It does FILE OPS ONLY — it never calls
# pacman — so it is safe to run inside a pacman transaction hook without deadlock.
set -euo pipefail
# rewrap <real_path> <orig_backup> <wrapper_dest>
rewrap() {
local real="$1" orig="$2" wrapper="$3"
# Nothing to point at — skip rather than create a dangling symlink.
if [[ ! -e $wrapper ]]; then
echo "rewrap: wrapper missing, skipping: $wrapper" >&2
return 0
fi
if [[ ! -L $real ]]; then
# Real binary present (fresh install, or an upgrade replaced our symlink):
# refresh the .orig backup, then re-point the symlink at the wrapper.
# When $real IS already our symlink we must NOT copy — that would put the
# wrapper into .orig and cause an exec loop.
[[ -e $real ]] && cp -f "$real" "$orig"
fi
ln -sf "$wrapper" "$real"
}
rewrap /usr/bin/pacman /usr/bin/pacman.orig /usr/local/bin/pacman_wrapper
rewrap /usr/bin/makepkg /usr/bin/makepkg.orig /usr/local/bin/makepkg_wrapper

View File

@ -1,163 +0,0 @@
#!/usr/bin/env bash
# Remove Guest Mode in Chromium-based browsers (especially thorium-browser) on Arch Linux
# - Applies enterprise policies at system level to hide/disable Guest mode and adding new people
# - Supports: thorium-browser, chromium, google-chrome(-stable), brave-browser, vivaldi, microsoft-edge-stable, opera
# - Provides --undo mode
set -euo pipefail
SCRIPT_NAME=$(basename "$0")
UNDO=false
for arg in "$@"; do
case "$arg" in
--undo) UNDO=true ;;
-h | --help)
cat <<EOF
Usage: $SCRIPT_NAME [--undo]
Actions:
(default) Write managed policy JSON to disable Guest mode
--undo Remove the policy files created by this script
Options:
-h,--help Show this help
Notes:
- Requires root privileges to write to /etc/* policy paths. Will self-elevate via sudo.
- Restart affected browsers to apply changes.
EOF
exit 0
;;
esac
done
# Re-exec as root if needed
if [[ $EUID -ne 0 ]]; then
echo "[info] Elevating privileges with sudo..."
exec sudo -E bash "$0" "$@"
fi
# Map binaries to a logical product key
declare -A BIN_TO_KEY=(
[thorium - browser]=thorium-browser
[thorium]=thorium-browser
[chromium]=chromium
[google - chrome]=google-chrome
[google - chrome - stable]=google-chrome
[brave - browser]=brave-browser
[vivaldi]=vivaldi
[vivaldi - stable]=vivaldi
[microsoft - edge - stable]=microsoft-edge-stable
[opera]=opera
)
# Candidate policy directories per product key (first existing or first creatable is used)
declare -A CANDIDATE_DIRS=(
[thorium - browser]="/etc/thorium/policies/managed:/etc/opt/thorium/policies/managed:/etc/opt/thorium-browser/policies/managed:/etc/thorium-browser/policies/managed"
[chromium]="/etc/chromium/policies/managed"
[google - chrome]="/etc/opt/chrome/policies/managed"
[brave - browser]="/etc/opt/brave/policies/managed"
[vivaldi]="/etc/opt/vivaldi/policies/managed"
[microsoft - edge - stable]="/etc/opt/edge/policies/managed"
[opera]="/etc/opt/opera/policies/managed"
)
POLICY_FILENAME="99-disable-guest-mode.json"
POLICY_JSON='{
"BrowserGuestModeEnabled": false,
"BrowserAddPersonEnabled": false
}'
# Discover installed browsers
declare -A INSTALLED_KEYS=()
for bin in "${!BIN_TO_KEY[@]}"; do
if command -v "$bin" >/dev/null 2>&1; then
key=${BIN_TO_KEY[$bin]}
INSTALLED_KEYS[$key]=1
fi
done
if [[ ${#INSTALLED_KEYS[@]} -eq 0 ]]; then
echo "[warn] No supported Chromium-based browsers detected in PATH. Proceeding to configure Thorium paths anyway."
INSTALLED_KEYS[thorium - browser]=1
fi
choose_target_dir() {
local key="$1"
local IFS=":"
local dirs
read -r -a dirs <<<"${CANDIDATE_DIRS[$key]:-}"
# Prefer an existing directory; else pick the first candidate
for d in "${dirs[@]}"; do
if [[ -d $d ]]; then
echo "$d"
return 0
fi
done
echo "${dirs[0]}"
}
apply_policy() {
local target_dir="$1"
shift
local file="$target_dir/$POLICY_FILENAME"
echo "[apply] $file"
mkdir -p "$target_dir"
# Write atomically
local tmp
tmp=$(mktemp)
printf '%s
' "$POLICY_JSON" >"$tmp"
install -m 0644 "$tmp" "$file"
rm -f "$tmp"
}
remove_policy() {
local target_dir="$1"
shift
local file="$target_dir/$POLICY_FILENAME"
if [[ -f $file ]]; then
echo "[remove] $file"
rm -f -- "$file"
else
echo "[skip] $file (not present)"
fi
}
changed_any=false
for key in "${!INSTALLED_KEYS[@]}"; do
# If we somehow lack candidate dirs for a key, skip gracefully
if [[ -z ${CANDIDATE_DIRS[$key]:-} ]]; then
echo "[warn] No known policy directories for '$key'; skipping."
continue
fi
target_dir=$(choose_target_dir "$key")
if [[ $UNDO == true ]]; then
remove_policy "$target_dir"
else
apply_policy "$target_dir"
fi
changed_any=true
done
if [[ $changed_any == false ]]; then
echo "[info] Nothing to do."
fi
if [[ $UNDO == true ]]; then
echo "[done] Guest mode policy files removed where present. You may need to restart the browsers."
else
echo "[done] Guest mode disabled via managed policies. Please fully restart affected browsers."
echo " If the Guest option still appears, it should be disabled/greyed out."
fi

View File

@ -1,125 +0,0 @@
#!/usr/bin/env node
/**
* seed_leechblock_storage.js
*
* Writes LeechBlock NG default settings directly into Chrome's extension
* LevelDB storage. This bypasses Chrome's content-verification and service
* worker caching entirely.
*
* Usage:
* node seed_leechblock_storage.js <path/to/defaults.js> [--force]
*
* Must be run while Chrome is NOT open.
*
* Requires: classic-level (npm install classic-level)
*/
import { ClassicLevel } from "classic-level";
import { readFileSync, existsSync } from "fs";
import path from "path";
import os from "os";
// ── CLI args ─────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const force = args.includes("--force");
const defaultsPath = args.find((a) => !a.startsWith("--"));
if (!defaultsPath) {
console.error("Usage: node seed_leechblock_storage.js <defaults.js> [--force]");
process.exit(1);
}
if (!existsSync(defaultsPath)) {
console.error(`defaults.js not found: ${defaultsPath}`);
process.exit(1);
}
// ── Load defaults from .json or .js file ────────────────────────────
const src = readFileSync(defaultsPath, "utf8");
let DEFAULTS;
try {
if (defaultsPath.endsWith(".json")) {
DEFAULTS = JSON.parse(src);
} else {
// Legacy .js format: const LEECHBLOCK_DEFAULTS = { ... }
// eslint-disable-next-line no-new-func
DEFAULTS = new Function(src + "\nreturn LEECHBLOCK_DEFAULTS;")();
}
} catch (e) {
console.error("Failed to load defaults file:", e.message);
process.exit(1);
}
console.log(`Loaded ${Object.keys(DEFAULTS).length} keys from ${defaultsPath}`);
// ── Known CWS extension ID for LeechBlock NG ─────────────────────────
const EXT_ID = "blaaajhemilngeeffpbfkdjjoefldkok";
// ── Find all Chrome/Chromium profile dirs with this extension ────────
const configDirs = [
path.join(os.homedir(), ".config/google-chrome"),
path.join(os.homedir(), ".config/chromium"),
path.join(os.homedir(), ".config/BraveSoftware/Brave-Browser"),
path.join(os.homedir(), ".config/vivaldi"),
path.join(os.homedir(), ".config/thorium"),
];
async function seedProfile(storageDir) {
const db = new ClassicLevel(storageDir, { createIfMissing: true });
try {
// Check for existing sites unless --force
if (!force) {
let hasAnySites = false;
const numSets = +(DEFAULTS.numSets ?? 6);
for (let s = 1; s <= numSets; s++) {
try {
const val = await db.get(`sites${s}`);
if (val && JSON.parse(val)) {
hasAnySites = true;
break;
}
} catch (_) {
/* key doesn't exist */
}
}
if (hasAnySites) {
console.log(` Skipping ${storageDir} — sites already configured (use --force to override).`);
return;
}
}
const entries = Object.entries(DEFAULTS);
for (const [key, value] of entries) {
await db.put(key, JSON.stringify(value));
}
console.log(` ✓ Seeded ${entries.length} settings into ${storageDir}`);
} finally {
await db.close();
}
}
let found = false;
const { readdirSync } = await import("fs");
for (const configDir of configDirs) {
if (!existsSync(configDir)) continue;
// Walk profiles: Default, Profile 1, Profile 2, ...
for (const profile of readdirSync(configDir)) {
const storageDir = path.join(
configDir, profile, "Local Extension Settings", EXT_ID
);
const extDir = path.join(configDir, profile, "Extensions", EXT_ID);
// Only seed profiles where LeechBlock is actually installed
if (existsSync(extDir) || existsSync(storageDir)) {
console.log(`Seeding ${configDir}/${profile}...`);
try {
await seedProfile(storageDir);
found = true;
} catch (e) {
console.warn(` ⚠ Failed to seed ${storageDir}: ${e.message}`);
}
}
}
}
if (!found) {
console.log("No LeechBlock NG installations found.");
process.exit(0);
}

View File

@ -1,179 +0,0 @@
#!/usr/bin/env node
/**
* seed_ublock_storage.js
*
* Writes a uBlock Origin `selectedFilterLists` entry directly into Chrome's
* extension LevelDB storage, enabling every filter list uBO ships (not just
* the defaults that are on out of the box). Same mechanism as
* seed_leechblock_storage.js: chrome.storage.local is a LevelDB store under
* "Local Extension Settings/<extension-id>/", writable while the browser is
* closed.
*
* uBO downloads/compiles the content of any newly-enabled 3rd-party list on
* its own update cycle after the extension's first real launch (~105s), so
* newly-selected lists need one browser launch with network access before
* they're actually enforced — this only sets which lists are selected.
*
* Usage:
* node seed_ublock_storage.js [--force] [--ext-path=/path/to/unpacked/ublock]
*
* --ext-path overrides the unpacked install path to match against (defaults
* to Arch's /usr/lib/ublock-origin). Pass this on non-Arch installers whose
* package path differs, e.g. Debian/Ubuntu's
* /usr/share/chromium/extensions/ublock-origin.
*
* Must be run while the browser is NOT open.
*
* Requires: classic-level (npm install classic-level)
*/
import { ClassicLevel } from "classic-level";
import { existsSync, readdirSync, readFileSync } from "fs";
import path from "path";
import os from "os";
import https from "https";
// ── CLI args ─────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const force = args.includes("--force");
const extPathArg = args.find((a) => a.startsWith("--ext-path="));
const customExtPath = extPathArg ? extPathArg.slice("--ext-path=".length) : null;
// ── Known CWS extension ID for uBlock Origin (MV2) ────────────────────
const CWS_EXT_ID = "cjpalhdlnbpafiamejdnhcphjbkeiagm";
// uBO's package install path(s), used with --load-extension deployments.
// Chromium derives a synthetic, install-specific extension ID from the
// unpacked path rather than using the CWS ID — read it back out of the
// profile's own Preferences file instead of guessing it. Includes both
// known distro package paths so this script works unmodified whether it
// was seeded via --ext-path (e.g. from the Ubuntu installer) or run bare
// (Arch's ublock-origin package).
const UNPACKED_EXT_PATHS = customExtPath
? [customExtPath]
: ["/usr/lib/ublock-origin", "/usr/share/chromium/extensions/ublock-origin"];
// Find the extension ID Chromium assigned to an unpacked extension loaded via
// --load-extension, by matching extensions.settings[*].path against known
// install paths. Returns null if the profile has no Preferences file yet
// (i.e. the browser has never been launched with this profile).
function findUnpackedExtensionId(configDir, profile) {
const prefsPath = path.join(configDir, profile, "Preferences");
if (!existsSync(prefsPath)) return null;
let prefs;
try {
prefs = JSON.parse(readFileSync(prefsPath, "utf8"));
} catch (_) {
return null;
}
const settings = prefs?.extensions?.settings ?? {};
for (const [id, entry] of Object.entries(settings)) {
if (UNPACKED_EXT_PATHS.includes(entry?.path)) return id;
}
return null;
}
// ── Fetch uBO's own filter-list catalog and select every filter list ──
function fetchAssetsJson() {
return new Promise((resolve, reject) => {
https
.get(
"https://raw.githubusercontent.com/gorhill/uBlock/master/assets/assets.json",
(res) => {
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode} fetching assets.json`));
return;
}
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
try {
resolve(JSON.parse(body));
} catch (e) {
reject(e);
}
});
},
)
.on("error", reject);
});
}
function selectAllFilterListKeys(assets) {
// Every entry with content === "filters" is a filter list (as opposed to
// uBO's own resource/scriptlet/redirect assets, which use other content
// types). "off: true" only means "not selected by default" — it is still
// a valid, selectable list, which is exactly what we want to force on.
return Object.entries(assets)
.filter(([, v]) => v.content === "filters")
.map(([key]) => key);
}
// ── Find all Chrome/Chromium profile dirs with this extension ────────
const configDirs = [
path.join(os.homedir(), ".config/google-chrome"),
path.join(os.homedir(), ".config/chromium"),
path.join(os.homedir(), ".config/BraveSoftware/Brave-Browser"),
path.join(os.homedir(), ".config/vivaldi"),
path.join(os.homedir(), ".config/thorium"),
path.join(os.homedir(), ".config/ungoogled-chromium"),
];
async function seedProfile(storageDir, selectedFilterLists) {
const db = new ClassicLevel(storageDir, { createIfMissing: true });
try {
if (!force) {
try {
const existing = await db.get("selectedFilterLists");
if (existing && JSON.parse(existing).length > 0) {
console.log(` Skipping ${storageDir} — filter lists already configured (use --force to override).`);
return;
}
} catch (_) {
/* key doesn't exist — fine, proceed to seed */
}
}
await db.put("selectedFilterLists", JSON.stringify(selectedFilterLists));
console.log(` ✓ Selected ${selectedFilterLists.length} filter lists in ${storageDir}`);
} finally {
await db.close();
}
}
console.log("Fetching uBlock Origin's filter-list catalog...");
const assets = await fetchAssetsJson();
const selectedFilterLists = selectAllFilterListKeys(assets);
console.log(`Found ${selectedFilterLists.length} selectable filter lists (enabling all of them).`);
let found = false;
for (const configDir of configDirs) {
if (!existsSync(configDir)) continue;
for (const profile of readdirSync(configDir)) {
// --load-extension deployments (this repo's normal case): Chromium
// assigns a synthetic ID derived from the unpacked path, discoverable
// only via the profile's own Preferences file.
const unpackedId = findUnpackedExtensionId(configDir, profile);
// Real CWS installs (e.g. Brave's Chrome Web Store install): fixed ID.
const cwsExtDir = path.join(configDir, profile, "Extensions", CWS_EXT_ID);
const cwsStorageDir = path.join(configDir, profile, "Local Extension Settings", CWS_EXT_ID);
const cwsInstalled = existsSync(cwsExtDir) || existsSync(cwsStorageDir);
const extId = unpackedId ?? (cwsInstalled ? CWS_EXT_ID : null);
if (!extId) continue;
const storageDir = path.join(configDir, profile, "Local Extension Settings", extId);
console.log(`Seeding ${configDir}/${profile} (extension id ${extId})...`);
try {
await seedProfile(storageDir, selectedFilterLists);
found = true;
} catch (e) {
console.warn(` ⚠ Failed to seed ${storageDir}: ${e.message}`);
}
}
}
if (!found) {
console.log("No uBlock Origin installations found.");
process.exit(0);
}

View File

@ -1,109 +0,0 @@
#!/bin/bash
# Script to set up automatic PC shutdown with day-specific time windows
# Monday-Wednesday: Shutdown between 21:00-05:00
# Thursday-Sunday: Shutdown between 22:00-05:00
# Handles sudo privileges automatically
set -e # Exit on any error
# Source common library for shared functions
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# shellcheck source=../../lib/common.sh
source "$SCRIPT_DIR/../../lib/common.sh"
# The phases live in lib/ms_*.sh. SCRIPT_DIR is resolved with readlink -f
# above, so this resolves through a symlinked entry point too.
# shellcheck source=lib/ms_guard.sh
source "$SCRIPT_DIR/lib/ms_guard.sh"
# shellcheck source=lib/ms_config_guard.sh
source "$SCRIPT_DIR/lib/ms_config_guard.sh"
# shellcheck source=lib/ms_scripts.sh
source "$SCRIPT_DIR/lib/ms_scripts.sh"
# shellcheck source=lib/ms_override_mgr.sh
source "$SCRIPT_DIR/lib/ms_override_mgr.sh"
# shellcheck source=lib/ms_monitor.sh
source "$SCRIPT_DIR/lib/ms_monitor.sh"
# shellcheck source=lib/ms_units.sh
source "$SCRIPT_DIR/lib/ms_units.sh"
# shellcheck source=lib/ms_report.sh
source "$SCRIPT_DIR/lib/ms_report.sh"
# shellcheck source=lib/ms_setup_flow.sh
source "$SCRIPT_DIR/lib/ms_setup_flow.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=21
# Thu-Sun aligned to 21:00 to match the canonical schedule that screen_locker's
# sick-day feature ratcheted in (was 22); the ratchet only permits same/stricter.
SCHEDULE_THU_SUN_HOUR=21
SCHEDULE_MORNING_END_HOUR=5
# ============================================================================
# SCHEDULE PROTECTION MECHANISM
# ============================================================================
# This prevents easy "cheating" by modifying the script values and re-running.
# If a canonical config already exists, the script compares against it and
# BLOCKS installation if the new values would make the schedule MORE LENIENT
# (i.e., later shutdown hours or earlier morning end).
#
# The mechanical protection (chattr, canonical snapshot, path watcher,
# pacman-hook) is provided by guard-lib (guardctl); this ratchet logic and
# the conditional-delay unlock flow below are specific to this one guard
# target and stay bespoke - guardctl's generic `unlock` can't represent
# "hard-block one field, delay only if lenient, no delay if stricter".
# ============================================================================
GUARD_NAME="shutdown-schedule"
CONFIG_FILE="/etc/shutdown-schedule.conf"
# Validate that the schedule allows at least MIN_USAGE_HOURS of continuous PC usage.
# The usable window is from SCHEDULE_MORNING_END_HOUR until each shutdown hour.
# Both shutdown hours must independently satisfy the minimum (10 hours).
MIN_USAGE_HOURS=10
# Validate schedule constants immediately (before any sudo escalation or file writes)
validate_minimum_usage_window
# Get the actual user (even when running with sudo)
set_actual_user_vars
# Parse command line arguments
case "${1:-enable}" in
"enable")
check_sudo "$@"
enable_midnight_shutdown
;;
"status")
check_sudo "$@"
show_current_status
;;
"help" | "-h" | "--help")
show_usage
;;
*)
echo "Error: Unknown command '$1'"
echo ""
show_usage
exit 1
;;
esac

View File

@ -1,127 +0,0 @@
#!/bin/bash
# ============================================================================
# setup_night_lockdown.sh
#
# Installs the "night lockdown" action that REPLACES the midnight power-off.
#
# This machine is a 24/7 home server (Gitea, the Caddy TLS edge, SyncYomi, the
# personal website, Open WebUI, Joplin, dufs, ollama, dnsmasq, wg-quick@wg0,
# nftables, sshd). The old digital-wellbeing curfew powered the PC off at night,
# which also took every server down. Night lockdown keeps the curfew's lockout
# intent but leaves the machine ON: it tears down the user GUI and masks the
# TTY login surface so the machine is unusable from the keyboard, while every
# background server keeps running. At 05:00 a morning timer restores the desktop.
#
# The evening/morning SCHEDULE and its anti-tamper guards still live in
# setup_midnight_shutdown.sh; that script's terminal action is swapped to call
# night-lockdown-enter.sh instead of powering off. This installer owns only the
# lock/unlock action and the morning-unlock timer, and is deliberately NOT made
# immutable so the reversal path can always be iterated and can never be taken
# down by a bug in the (guarded) lock path.
#
# HARD LOCKOUT: the only recovery path once locked is SSH (WireGuard / LAN).
# `setup_night_lockdown.sh unlock` restores the GUI immediately over SSH.
#
# Usage:
# sudo ./setup_night_lockdown.sh setup # install / re-install (idempotent)
# ./setup_night_lockdown.sh status # show current state
# sudo ./setup_night_lockdown.sh unlock # emergency: lift lockdown now (SSH)
# ./setup_night_lockdown.sh help
# ============================================================================
set -euo pipefail
# shellcheck source=lib/nl_commands.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/nl_commands.sh"
# shellcheck source=lib/nl_config.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/nl_config.sh"
# shellcheck source=lib/nl_units.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/nl_units.sh"
# shellcheck source=lib/nl_unlock.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/nl_unlock.sh"
# shellcheck source=lib/nl_enter.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/nl_enter.sh"
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# shellcheck source=../../lib/common.sh
source "$SCRIPT_DIR/../../lib/common.sh"
# --- Installed artifact locations -------------------------------------------
readonly ENTER_SCRIPT="/usr/local/bin/night-lockdown-enter.sh"
readonly UNLOCK_SCRIPT="/usr/local/bin/night-lockdown-unlock.sh"
readonly CONF_FILE="/etc/night-lockdown.conf"
readonly STATE_DIR="/var/lib/night-lockdown"
readonly STATE_FILE="$STATE_DIR/state"
readonly I2C_MODULES_FILE="/etc/modules-load.d/night-lockdown-i2c.conf"
readonly UNLOCK_SERVICE="/etc/systemd/system/night-lockdown-unlock.service"
readonly UNLOCK_TIMER="/etc/systemd/system/night-lockdown-unlock.timer"
readonly RGB_OFF_SERVICE="/etc/systemd/system/rgb-off.service"
readonly OVERRIDE_MANAGER="/usr/local/bin/shutdown-override-manager.sh"
# HOME openrgb runs with; must match RGB_HOME in the generated config.
readonly RGB_HOME_DEFAULT="/root"
readonly MANAGED_BANNER="# Managed by setup_night_lockdown.sh — do not edit by hand."
# =============================================================================
# Hardware / environment detection (run at install time, written into the conf)
# =============================================================================
# =============================================================================
# The lock action — installed to $ENTER_SCRIPT
# =============================================================================
# =============================================================================
# The reversal — installed to $UNLOCK_SCRIPT
# =============================================================================
# =============================================================================
# Morning-unlock systemd timer family (outside the shutdown fortress)
# =============================================================================
install_i2c_modules() {
log_info "Installing i2c module-load for future RGB support"
cat >"$I2C_MODULES_FILE" <<EOF
$MANAGED_BANNER
# SMBus/i2c so OpenRGB can eventually reach motherboard/RAM RGB controllers.
i2c-dev
i2c-piix4
EOF
chmod 0644 "$I2C_MODULES_FILE"
# Load now too (harmless if already loaded); ignore failure on odd hardware.
modprobe i2c-dev 2>/dev/null || true
modprobe i2c-piix4 2>/dev/null || true
}
# =============================================================================
# Install verification
# =============================================================================
# =============================================================================
# Subcommands
# =============================================================================
main() {
local cmd="${1:-setup}"
# Elevate BEFORE shifting so the subcommand survives the sudo re-exec.
case "$cmd" in
setup | unlock) require_root "$@" ;;
esac
shift || true
case "$cmd" in
setup) cmd_setup "$@" ;;
status) cmd_status "$@" ;;
unlock) cmd_unlock "$@" ;;
help | -h | --help) usage ;;
*)
log_error "Unknown command: $cmd"
usage
exit 1
;;
esac
}
main "$@"

View File

@ -1,25 +0,0 @@
[Unit]
Description=Music Parallelism Prevention - Stops music when focus apps are running
After=graphical-session.target
Wants=graphical-session.target
[Service]
Type=simple
ExecStart=/usr/local/bin/music-parallelism.sh instant
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
# Run as user (needs access to X11/Wayland for window detection)
# This will be set during installation based on the actual user
# Environment for X11/Wayland access
Environment=DISPLAY=:0
# Resource limits
MemoryMax=50M
CPUQuota=5%
[Install]
WantedBy=default.target

View File

@ -1,220 +0,0 @@
#!/bin/bash
# filepath: enforce_vbox_hosts.sh
# Enforce host machine's /etc/hosts file on all VirtualBox VMs
# This prevents VMs from bypassing host-level content filtering
set -euo pipefail
# shellcheck source=lib/vbox_disk_inject.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/vbox_disk_inject.sh"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Auto-sudo functionality with confirmation
if [ "$EUID" -ne 0 ]; then
echo -e "${YELLOW}This script requires root privileges to configure VirtualBox VMs.${NC}"
echo -e "${CYAN}Executing with sudo...${NC}"
exec sudo bash "$0" "$@"
fi
# Determine the real (non-root) user who invoked this script.
# VBoxManage must run as this user because VMs are registered per-user.
REAL_USER="${SUDO_USER:-$USER}"
if [[ $REAL_USER == "root" ]]; then
echo -e "${RED}Cannot determine the real user. Do not run this script as root directly.${NC}"
echo -e "${RED}Run it as a normal user (it will auto-sudo as needed).${NC}"
exit 1
fi
# Check if VBoxManage is available
if ! command -v VBoxManage >/dev/null 2>&1; then
echo -e "${RED}VBoxManage not found. VirtualBox may not be installed.${NC}"
exit 1
fi
# Run VBoxManage as the real user so it sees their registered VMs
vboxmanage_as_user() {
sudo -u "$REAL_USER" VBoxManage "$@"
}
# Configuration
VBOX_SHARED_FOLDER_NAME="host_etc"
HOSTS_ENFORCEMENT_MARKER="/var/lib/vbox-hosts-enforced"
# Get list of all VMs
get_all_vms() {
vboxmanage_as_user list vms | awk -F'"' '{print $2}'
}
# Get list of running VMs
get_running_vms() {
vboxmanage_as_user list runningvms | awk -F'"' '{print $2}'
}
# Configure a VM to use host DNS (NAT network)
configure_vm_dns() {
local vm_name="$1"
echo -e "${BLUE}Configuring DNS for VM: ${vm_name}${NC}"
# Enable DNS proxy for NAT adapter (adapter 1 by default)
# This makes the VM use the host's DNS resolution
vboxmanage_as_user modifyvm "$vm_name" --natdnshostresolver1 on 2>/dev/null || true
vboxmanage_as_user modifyvm "$vm_name" --natdnsproxy1 on 2>/dev/null || true
echo -e "${GREEN}DNS configuration applied to ${vm_name}${NC}"
}
# Add shared folder for /etc directory (read-only)
configure_hosts_shared_folder() {
local vm_name="$1"
echo -e "${BLUE}Setting up /etc/hosts sharing for VM: ${vm_name}${NC}"
# Remove existing shared folder if present
vboxmanage_as_user sharedfolder remove "$vm_name" --name "$VBOX_SHARED_FOLDER_NAME" 2>/dev/null || true
# Add /etc as a shared folder (read-only)
vboxmanage_as_user sharedfolder add "$vm_name" \
--name "$VBOX_SHARED_FOLDER_NAME" \
--hostpath "/etc" \
--readonly \
--automount 2>/dev/null || {
echo -e "${YELLOW}Could not add shared folder to ${vm_name} (VM may be running)${NC}"
return 1
}
echo -e "${GREEN}Shared folder configured for ${vm_name}${NC}"
return 0
}
# Create a startup script that can be placed in VMs
generate_vm_startup_script() {
local output_file="${1:-/tmp/vbox_hosts_sync.sh}"
cat >"$output_file" <<'EOF'
#!/bin/bash
# VirtualBox VM startup script to sync /etc/hosts from host machine
# This should be placed in the VM and run at startup
set -e
SHARED_FOLDER_MOUNT="/mnt/host_etc"
HOST_HOSTS_FILE="${SHARED_FOLDER_MOUNT}/hosts"
VM_HOSTS_FILE="/etc/hosts"
BACKUP_HOSTS_FILE="/etc/hosts.pre-vbox-sync"
# Function to check if running in VirtualBox
is_virtualbox() {
# First try systemd-detect-virt (no root required)
if command -v systemd-detect-virt > /dev/null 2>&1; then
if systemd-detect-virt 2>/dev/null | grep -qi "oracle"; then
return 0
fi
fi
# Then try dmidecode (requires root, but script should already be running as root)
if command -v dmidecode > /dev/null 2>&1; then
if dmidecode -s system-product-name 2>/dev/null | grep -qi "VirtualBox"; then
return 0
fi
fi
return 1
}
# Only run if we're in VirtualBox
if ! is_virtualbox; then
exit 0
fi
# Create mount point if it doesn't exist
mkdir -p "$SHARED_FOLDER_MOUNT"
# Try to mount the shared folder (if Guest Additions are installed)
if ! mountpoint -q "$SHARED_FOLDER_MOUNT"; then
if command -v mount.vboxsf > /dev/null 2>&1; then
mount -t vboxsf -o ro host_etc "$SHARED_FOLDER_MOUNT" 2>/dev/null || {
echo "Could not mount VirtualBox shared folder"
exit 0
}
else
echo "VirtualBox Guest Additions not installed, cannot sync hosts file"
exit 0
fi
fi
# Sync hosts file if the shared one exists
if [ -f "$HOST_HOSTS_FILE" ]; then
# Backup current hosts file if not already backed up
if [ ! -f "$BACKUP_HOSTS_FILE" ]; then
cp "$VM_HOSTS_FILE" "$BACKUP_HOSTS_FILE"
fi
# Copy host's hosts file to VM
cp "$HOST_HOSTS_FILE" "$VM_HOSTS_FILE"
echo "Synced /etc/hosts from host machine"
# Make it harder to modify (though not impossible in VM)
chmod 444 "$VM_HOSTS_FILE"
fi
EOF
chmod +x "$output_file"
echo -e "${GREEN}Generated VM startup script at ${output_file}${NC}"
echo -e "${CYAN}Copy this script to your VMs and add it to their startup (e.g., /etc/rc.local or systemd)${NC}"
}
# Main function
main() {
local action="${1:-enforce}"
case "$action" in
enforce | apply)
enforce_all_vms
;;
check)
if check_enforcement_status; then
exit 0
else
exit 1
fi
;;
status)
show_status
;;
generate-script)
local output="${2:-/tmp/vbox_hosts_sync.sh}"
generate_vm_startup_script "$output"
;;
*)
echo -e "${CYAN}VirtualBox /etc/hosts Enforcement Tool${NC}"
echo ""
echo "Usage: $0 [command]"
echo ""
echo "Commands:"
echo " enforce Apply /etc/hosts enforcement to all VMs (default)"
echo " check Check if enforcement has been applied"
echo " status Show current enforcement status"
echo " generate-script [path] Generate a script to place in VMs for hosts sync"
echo ""
echo "This tool configures VirtualBox VMs to:"
echo " 1. Use host's DNS resolution (via NAT DNS proxy)"
echo " 2. Share /etc from host (read-only) for hosts file access"
echo ""
exit 0
;;
esac
}
main "$@"

View File

@ -1,236 +0,0 @@
#!/usr/bin/env bash
# Guest-disk hosts injection and enforcement status for enforce_vbox_hosts.sh.
#
# Sourced by the entry script, which owns the CLI and the VM enumeration.
# Get the disk image path for a VM (first SATA/IDE .vdi/.vmdk/.vhd)
get_vm_disk_path() {
local vm_name="$1"
vboxmanage_as_user showvminfo "$vm_name" --machinereadable 2>/dev/null |
grep -E '^"(SATA|IDE|SCSI|NVMe)-[0-9]+-[0-9]+"=' |
grep -vE '="none"$' |
grep -vE '\.iso"$' |
head -1 |
sed 's/^[^=]*="//; s/"$//'
}
# Inject host's /etc/hosts directly into a VM disk image using qemu-nbd.
# This is the only reliable way to enforce blocking, because NAT DNS proxy
# alone does not work when the guest browser uses DNS-over-HTTPS (DoH).
inject_hosts_into_vm_disk() {
local vm_name="$1"
local disk_path
disk_path="$(get_vm_disk_path "$vm_name")"
if [[ -z $disk_path || ! -f $disk_path ]]; then
echo -e "${YELLOW}Could not find disk image for VM '${vm_name}', skipping hosts injection${NC}"
return 1
fi
# Ensure VM is not running
if vboxmanage_as_user list runningvms 2>/dev/null | grep -q "\"${vm_name}\""; then
echo -e "${YELLOW}VM '${vm_name}' is running, cannot inject hosts file. Stop it first.${NC}"
return 1
fi
# Check for qemu-nbd
if ! command -v qemu-nbd >/dev/null 2>&1; then
echo -e "${YELLOW}qemu-nbd not found. Install qemu-base to enable hosts file injection.${NC}"
return 1
fi
echo -e "${BLUE}Injecting /etc/hosts into disk image for VM: ${vm_name}${NC}"
# Load nbd module if needed
if [[ ! -e /dev/nbd0 ]]; then
modprobe nbd max_part=8 2>/dev/null || {
echo -e "${YELLOW}Could not load nbd kernel module${NC}"
return 1
}
fi
# Find a free nbd device
local nbd_dev=""
for dev in /dev/nbd{0..15}; do
if [[ -e $dev ]] && ! lsblk "$dev" >/dev/null 2>&1; then
nbd_dev="$dev"
break
fi
done
# Fallback: try /dev/nbd0 if no device was found via lsblk check
if [[ -z $nbd_dev ]]; then
nbd_dev="/dev/nbd0"
fi
local mount_point="/tmp/vbox_hosts_inject_$$"
# Connect disk image
qemu-nbd --connect="$nbd_dev" "$disk_path" 2>/dev/null || {
echo -e "${YELLOW}Could not connect disk image via qemu-nbd${NC}"
return 1
}
# Wait for partitions to appear
sleep 1
partprobe "$nbd_dev" 2>/dev/null || true
# Find the root partition (first Linux partition)
local part=""
for p in "${nbd_dev}p1" "${nbd_dev}p2" "${nbd_dev}p3"; do
if [[ -b $p ]]; then
part="$p"
break
fi
done
if [[ -z $part ]]; then
echo -e "${YELLOW}No partitions found on disk image${NC}"
qemu-nbd --disconnect "$nbd_dev" 2>/dev/null || true
return 1
fi
# Mount the partition
mkdir -p "$mount_point"
if ! mount "$part" "$mount_point" 2>/dev/null; then
# Journal may need recovery — run e2fsck then retry
e2fsck -y "$part" >/dev/null 2>&1 || true
if ! mount "$part" "$mount_point" 2>/dev/null; then
echo -e "${YELLOW}Could not mount partition $part${NC}"
qemu-nbd --disconnect "$nbd_dev" 2>/dev/null || true
rmdir "$mount_point" 2>/dev/null || true
return 1
fi
fi
# Check if this partition has /etc/hosts (i.e., it's the root fs)
if [[ ! -f "$mount_point/etc/hosts" ]]; then
echo -e "${YELLOW}Partition does not appear to be root filesystem (no /etc/hosts)${NC}"
umount "$mount_point" 2>/dev/null || true
qemu-nbd --disconnect "$nbd_dev" 2>/dev/null || true
rmdir "$mount_point" 2>/dev/null || true
return 1
fi
# Backup original if not already backed up
if [[ ! -f "$mount_point/etc/hosts.original" ]]; then
cp "$mount_point/etc/hosts" "$mount_point/etc/hosts.original"
echo -e "${CYAN}Backed up original hosts file${NC}"
fi
# Copy host's /etc/hosts into VM
cp /etc/hosts "$mount_point/etc/hosts"
chmod 444 "$mount_point/etc/hosts"
local blocked_count
blocked_count="$(grep -c '0.0.0.0' "$mount_point/etc/hosts")"
# Cleanup: unmount and disconnect
umount "$mount_point" 2>/dev/null || true
qemu-nbd --disconnect "$nbd_dev" 2>/dev/null || true
rmdir "$mount_point" 2>/dev/null || true
echo -e "${GREEN}Hosts file injected into VM '${vm_name}' (${blocked_count} domains blocked)${NC}"
return 0
}
# Apply enforcement to all VMs
enforce_all_vms() {
local -a vms
mapfile -t vms < <(get_all_vms)
if [[ ${#vms[@]} -eq 0 ]]; then
echo -e "${YELLOW}No VirtualBox VMs found.${NC}"
return 0
fi
echo -e "${CYAN}Found ${#vms[@]} VM(s). Applying /etc/hosts enforcement...${NC}"
local success=0
local failed=0
for vm in "${vms[@]}"; do
echo -e "\n${BLUE}Processing VM: ${vm}${NC}"
# Configure DNS settings (works even when VM is running)
configure_vm_dns "$vm"
# Try to configure shared folder (only works when VM is stopped)
if configure_hosts_shared_folder "$vm"; then
((++success))
else
((++failed))
echo -e "${YELLOW}Note: Stop the VM and run this script again to add shared folder${NC}"
fi
# Inject hosts file directly into VM disk (the actual enforcement)
inject_hosts_into_vm_disk "$vm" || true
done
echo -e "\n${GREEN}Enforcement complete!${NC}"
echo -e "Successfully configured: ${success} VM(s)"
[[ $failed -gt 0 ]] && echo -e "${YELLOW}Needs VM shutdown for full config: ${failed} VM(s)${NC}"
# Mark that enforcement has been applied
touch "$HOSTS_ENFORCEMENT_MARKER"
}
# Check if a single VM has the shared folder configured
vm_has_shared_folder() {
local vm_name="$1"
vboxmanage_as_user showvminfo "$vm_name" --machinereadable 2>/dev/null |
grep -q "SharedFolderNameMachineMapping.*=\"${VBOX_SHARED_FOLDER_NAME}\""
}
# Check if enforcement is applied to ALL registered VMs
check_enforcement_status() {
local -a vms
mapfile -t vms < <(get_all_vms)
if [[ ${#vms[@]} -eq 0 ]]; then
echo -e "${GREEN}No VMs to enforce.${NC}"
return 0
fi
for vm in "${vms[@]}"; do
if ! vm_has_shared_folder "$vm"; then
echo -e "${YELLOW}VM '${vm}' is missing hosts enforcement.${NC}"
return 1
fi
done
echo -e "${GREEN}All ${#vms[@]} VM(s) have hosts enforcement applied.${NC}"
return 0
}
# Show status
show_status() {
echo -e "${CYAN}VirtualBox Hosts Enforcement Status${NC}"
echo -e "${CYAN}====================================${NC}\n"
local -a all_vms running_vms
mapfile -t all_vms < <(get_all_vms)
mapfile -t running_vms < <(get_running_vms)
echo -e "Total VMs: ${#all_vms[@]}"
echo -e "Running VMs: ${#running_vms[@]}"
if check_enforcement_status >/dev/null 2>&1; then
echo -e "Enforcement status: ${GREEN}Applied to all VMs${NC}"
else
echo -e "Enforcement status: ${RED}Not fully applied${NC}"
fi
echo -e "\n${CYAN}VMs:${NC}"
for vm in "${all_vms[@]}"; do
local flags=""
if printf '%s\n' "${running_vms[@]}" | grep -qx "$vm"; then
flags+=" ${GREEN}[RUNNING]${NC}"
fi
if vm_has_shared_folder "$vm"; then
flags+=" ${GREEN}[ENFORCED]${NC}"
else
flags+=" ${RED}[NOT ENFORCED]${NC}"
fi
echo -e " - ${vm}${flags}"
done
}

View File

@ -1,22 +0,0 @@
#!/bin/bash
# YouTube Music Wrapper - Blocks launch when focus apps are running
# This replaces the actual youtube-music binary
set -euo pipefail
# Source common library for shared functions
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
source "$SCRIPT_DIR/../../lib/common.sh"
REAL_BINARY="/opt/YouTube Music/youtube-music.real"
LOG_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/music-parallelism/music-parallelism.log"
# Main
if focus_app=$(is_focus_app_running); then
log_message "BLOCKED: YouTube Music launch prevented (focus app: $focus_app)" "$LOG_FILE"
notify "🚫 YouTube Music Blocked" "Focus mode active ($focus_app)" normal 3000
exit 1
fi
# No focus app running, launch normally
exec "$REAL_BINARY" "$@"

View File

@ -17,9 +17,10 @@ init_setup_script "Periodic System Setup - Pacman Wrapper & Hosts File" "$@"
CONFIG_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" CONFIG_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
# Define paths # Define paths
PACMAN_WRAPPER_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh" DW_REPO="$(require_extracted_repo digital-wellbeing "the periodic system setup")" || exit 1
PACMAN_WRAPPER_INSTALL="$CONFIG_DIR/periodic_background/digital_wellbeing/pacman/install_pacman_wrapper.sh" PACMAN_WRAPPER_SCRIPT="$DW_REPO/pacman/pacman_wrapper.sh"
MAKEPKG_WRAPPER_INSTALL="$CONFIG_DIR/periodic_background/digital_wellbeing/pacman/install_makepkg_wrapper.sh" PACMAN_WRAPPER_INSTALL="$DW_REPO/pacman/install_pacman_wrapper.sh"
MAKEPKG_WRAPPER_INSTALL="$DW_REPO/pacman/install_makepkg_wrapper.sh"
# hosts-blocker was EXTRACTED out of this monorepo # hosts-blocker was EXTRACTED out of this monorepo
# (github.com/kuhyx/hosts-blocker), so this path deliberately lives # (github.com/kuhyx/hosts-blocker), so this path deliberately lives
# outside it. A missing checkout is fatal, never a silent skip. # outside it. A missing checkout is fatal, never a silent skip.

View File

@ -1,70 +0,0 @@
#!/bin/bash
# Pacman wrapper security: makepkg capped runner, mkpkg helper and installer hardening (tests 13-19).
# Sourced by test_pacman_wrapper_security.sh; inherits its strict mode
# (set -e) and its SCRIPT_DIR / WRAPPER_DIR / VBOX_DIR paths.
pac_tests_makepkg_and_installer() {
# Test 13: Verify makepkg capped wrapper script syntax
echo "[TEST 13] Checking makepkg capped wrapper syntax..."
if bash -n "$WRAPPER_DIR/makepkg_capped.sh"; then
echo "✓ makepkg capped wrapper syntax is valid"
else
echo "✗ makepkg capped wrapper has syntax errors"
exit 1
fi
# Test 14: Verify pacman wrapper exposes makepkg capped command
echo "[TEST 14] Verifying pacman wrapper supports --makepkg-capped..."
if grep -q -- "--makepkg-capped" "$WRAPPER_DIR/pacman_wrapper.sh"; then
echo "✓ pacman wrapper makepkg capped command found"
else
echo "✗ pacman wrapper makepkg capped command missing"
exit 1
fi
# Test 15: Verify installer deploys makepkg capped wrapper
echo "[TEST 15] Verifying installer deploys makepkg capped wrapper..."
if grep -qr "MAKEPKG_CAPPED" "$WRAPPER_DIR/install_pacman_wrapper.sh" "$WRAPPER_DIR/lib"; then
echo "✓ Installer includes makepkg capped deployment"
else
echo "✗ Installer does not include makepkg capped deployment"
exit 1
fi
# Test 16: Verify mkpkg helper script syntax
echo "[TEST 16] Checking mkpkg helper script syntax..."
if bash -n "$WRAPPER_DIR/mkpkg.sh"; then
echo "✓ mkpkg helper script syntax is valid"
else
echo "✗ mkpkg helper script has syntax errors"
exit 1
fi
# Test 17: Verify installer deploys mkpkg helper
echo "[TEST 17] Verifying installer deploys mkpkg helper..."
if grep -qr "MKPKG" "$WRAPPER_DIR/install_pacman_wrapper.sh" "$WRAPPER_DIR/lib"; then
echo "✓ Installer includes mkpkg helper deployment"
else
echo "✗ Installer does not include mkpkg helper deployment"
exit 1
fi
# Test 18: Verify installer runs in strict mode
echo "[TEST 18] Verifying installer uses strict shell mode..."
if grep -q "set -euo pipefail" "$WRAPPER_DIR/install_pacman_wrapper.sh"; then
echo "✓ Installer strict mode enabled"
else
echo "✗ Installer strict mode not enabled"
exit 1
fi
# Test 19: Verify installer handles immutable files during updates
echo "[TEST 19] Verifying installer unlocks immutable files before copy/write..."
if grep -qr "unlock_immutable_file_if_needed" "$WRAPPER_DIR/install_pacman_wrapper.sh" "$WRAPPER_DIR/lib"; then
echo "✓ Installer immutable-file handling found"
else
echo "✗ Installer immutable-file handling missing"
exit 1
fi
}

View File

@ -1,119 +0,0 @@
#!/bin/bash
# Pacman wrapper security: syntax, integrity and VirtualBox enforcement checks (tests 1-12).
# Sourced by test_pacman_wrapper_security.sh; inherits its strict mode
# (set -e) and its SCRIPT_DIR / WRAPPER_DIR / VBOX_DIR paths.
pac_tests_wrapper_and_vbox() {
# Test 1: Check wrapper syntax
echo "[TEST 1] Checking wrapper script syntax..."
if bash -n "$WRAPPER_DIR/pacman_wrapper.sh"; then
echo "✓ Wrapper script syntax is valid"
else
echo "✗ Wrapper script has syntax errors"
exit 1
fi
# Test 2: Check installer syntax
echo "[TEST 2] Checking installer script syntax..."
if bash -n "$WRAPPER_DIR/install_pacman_wrapper.sh"; then
echo "✓ Installer script syntax is valid"
else
echo "✗ Installer script has syntax errors"
exit 1
fi
# Test 3: Check VirtualBox enforcement script syntax
echo "[TEST 3] Checking VirtualBox enforcement script syntax..."
if bash -n "$VBOX_DIR/enforce_vbox_hosts.sh"; then
echo "✓ VirtualBox enforcement script syntax is valid"
else
echo "✗ VirtualBox enforcement script has syntax errors"
exit 1
fi
# The wrapper was split into flat pw_*.sh libs under the 250-line cap, so
# these definition checks search the entry script AND its libs: the
# functions moved one file over, they did not disappear.
# Test 4: Verify integrity check function exists
echo "[TEST 4] Verifying integrity check function exists in wrapper..."
if grep -q "verify_policy_integrity()" "$WRAPPER_DIR/pacman_wrapper.sh" "$WRAPPER_DIR"/pw_*.sh; then
echo "✓ Integrity verification function found"
else
echo "✗ Integrity verification function not found"
exit 1
fi
# Test 5: Verify hardcoded VirtualBox cleanup function exists
echo "[TEST 5] Verifying hardcoded VirtualBox cleanup function exists..."
if grep -q "auto_remove_virtualbox_vms()" "$WRAPPER_DIR/pacman_wrapper.sh" "$WRAPPER_DIR"/pw_*.sh; then
echo "✓ Hardcoded VirtualBox cleanup function found"
else
echo "✗ Hardcoded VirtualBox cleanup function not found"
exit 1
fi
# Test 6: Verify VirtualBox cleanup uses VBoxManage directly
echo "[TEST 6] Verifying VirtualBox cleanup uses VBoxManage directly..."
if grep -q "VBoxManage" "$WRAPPER_DIR/pacman_wrapper.sh" "$WRAPPER_DIR"/pw_*.sh; then
echo "✓ VirtualBox cleanup logic found"
else
echo "✗ VirtualBox cleanup logic not found"
exit 1
fi
# Test 7: Verify integrity check is called early in execution
echo "[TEST 7] Verifying integrity check is called before operations..."
if grep -B 2 -A 2 "verify_policy_integrity" "$WRAPPER_DIR/pacman_wrapper.sh" | grep -q "CRITICAL"; then
echo "✓ Integrity check is called early in execution"
else
echo "✗ Integrity check not found in early execution"
exit 1
fi
# Test 8: Verify installer creates integrity file
echo "[TEST 8] Verifying installer creates integrity checksums..."
if grep -qr "INTEGRITY_FILE" "$WRAPPER_DIR/install_pacman_wrapper.sh" "$WRAPPER_DIR/lib"; then
echo "✓ Installer references integrity file"
else
echo "✗ Installer does not create integrity file"
exit 1
fi
# Test 9: Verify installer uses chattr to make files immutable
echo "[TEST 9] Verifying installer makes policy files immutable..."
if grep -qr "chattr +i" "$WRAPPER_DIR/install_pacman_wrapper.sh" "$WRAPPER_DIR/lib"; then
echo "✓ Installer sets immutable attributes"
else
echo "✗ Installer does not set immutable attributes"
exit 1
fi
# Test 10: Verify VirtualBox cleanup enforcement is integrated
echo "[TEST 10] Verifying VirtualBox cleanup is integrated into wrapper..."
if grep -q "auto_remove_virtualbox_vms" "$WRAPPER_DIR/pacman_wrapper.sh"; then
echo "✓ VirtualBox cleanup integration found"
else
echo "✗ VirtualBox cleanup integration not found"
exit 1
fi
# Test 11: Verify VirtualBox script can show help
echo "[TEST 11] Testing VirtualBox enforcement script help..."
# Run without invoking sudo by setting EUID check (or just check for the help text in the file)
if grep -q "VirtualBox /etc/hosts Enforcement Tool" "$VBOX_DIR/enforce_vbox_hosts.sh"; then
echo "✓ VirtualBox enforcement script has help text"
else
echo "✗ VirtualBox enforcement script help text not found"
exit 1
fi
# Test 12: Verify installer installs VirtualBox enforcement script
echo "[TEST 12] Verifying installer handles VirtualBox enforcement script..."
if grep -qr "VBOX_ENFORCE" "$WRAPPER_DIR/install_pacman_wrapper.sh" "$WRAPPER_DIR/lib"; then
echo "✓ Installer includes VirtualBox enforcement script"
else
echo "✗ Installer does not include VirtualBox enforcement script"
exit 1
fi
}

View File

@ -9,7 +9,7 @@ sec_tests_compulsive_block() {
# ================================================================== # ==================================================================
echo "--- COMPULSIVE OPENING BLOCK ---" echo "--- COMPULSIVE OPENING BLOCK ---"
compulsive_dir="$REPO_DIR/periodic_background/digital_wellbeing" compulsive_dir="$DW_REPO"
compulsive_file="$compulsive_dir/block_compulsive_opening.sh" compulsive_file="$compulsive_dir/block_compulsive_opening.sh"
# The blocker was split into lib/cco_*.sh under the 250-line cap, so these # The blocker was split into lib/cco_*.sh under the 250-line cap, so these

View File

@ -23,7 +23,7 @@ sec_tests_pacman_wrapper() {
live_section 0 # repository checks from here on live_section 0 # repository checks from here on
# Test 13: google-chrome is blocked # Test 13: google-chrome is blocked
blocked_file="$REPO_DIR/periodic_background/digital_wellbeing/pacman/pacman_blocked_keywords.txt" blocked_file="$DW_REPO/pacman/pacman_blocked_keywords.txt"
if [[ -f "$blocked_file" ]]; then if [[ -f "$blocked_file" ]]; then
if grep -qi "google-chrome" "$blocked_file"; then if grep -qi "google-chrome" "$blocked_file"; then
test_result "google-chrome in blocked list" "pass" test_result "google-chrome in blocked list" "pass"
@ -57,7 +57,7 @@ sec_tests_pacman_wrapper() {
live_section 0 live_section 0
# Test 16: LeechBlock auto-install function exists in wrapper # Test 16: LeechBlock auto-install function exists in wrapper
wrapper_file="$REPO_DIR/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh" wrapper_file="$DW_REPO/pacman/pacman_wrapper.sh"
if [[ -f "$wrapper_file" ]]; then if [[ -f "$wrapper_file" ]]; then
if grep -q "auto_install_leechblock" "$wrapper_file"; then if grep -q "auto_install_leechblock" "$wrapper_file"; then
test_result "LeechBlock auto-install function exists" "pass" test_result "LeechBlock auto-install function exists" "pass"

View File

@ -1,88 +0,0 @@
#!/bin/bash
# Regression tests for pacman wrapper / guard-lib hosts-guard fallback integration.
#
# The pre-guard-lib hooks this test used to check (pacman-pre-unlock-hosts.sh,
# pacman-post-relock-hosts.sh, hosts-guard-common.sh, install_pacman_hooks.sh)
# were archived to testsAndMisc-archive once guard-lib's generic pacman hooks
# (10-guard-lib-unlock-all.hook / 90-guard-lib-relock-all.hook) fully replaced
# them -- see docs/superpowers/evidence/archive-setup-hosts-guard-2026-08-18.json.
# Only pacman_wrapper.sh's guard-lib-aware fallback logic remains to check.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
WRAPPER_FILE="$REPO_DIR/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh"
# The wrapper was split into flat pw_*.sh libs under the 250-line cap, so a
# function-definition check has to search the entry script AND its libs.
# Grepping the entry script alone reports "not found" for code that simply
# moved one file over. The call-site ORDER assertions below still target the
# entry script, where the calls themselves remain.
WRAPPER_SOURCES=("$WRAPPER_FILE")
for _pw_lib in "$(dirname "$WRAPPER_FILE")"/pw_*.sh; do
[[ -f "$_pw_lib" ]] && WRAPPER_SOURCES+=("$_pw_lib")
done
assert_contains() {
local file_path="$1"
local pattern="$2"
local message="$3"
if grep -Fq "$pattern" "$file_path" "${WRAPPER_SOURCES[@]:1}"; then
echo "PASS: $message"
else
echo "FAIL: $message"
exit 1
fi
}
first_line_number() {
local file_path="$1"
local pattern="$2"
grep -n -F -m 1 "$pattern" "$file_path" | cut -d: -f1
}
assert_order() {
local file_path="$1"
local first_pattern="$2"
local second_pattern="$3"
local message="$4"
local first_line
local second_line
first_line="$(first_line_number "$file_path" "$first_pattern")"
second_line="$(first_line_number "$file_path" "$second_pattern")"
if [[ -z "$first_line" || -z "$second_line" ]]; then
echo "FAIL: $message"
exit 1
fi
if ((first_line < second_line)); then
echo "PASS: $message"
else
echo "FAIL: $message"
exit 1
fi
}
echo "=== Hosts guard pacman integration regression tests ==="
bash -n "$WRAPPER_FILE"
echo "PASS: shell syntax is valid"
assert_contains "$WRAPPER_FILE" 'pacman_hooks_manage_guard_lib()' \
"wrapper detects when guard-lib's pacman hooks are installed"
assert_contains "$WRAPPER_FILE" 'should_use_wrapper_guard_lib_fallback()' \
"wrapper exposes a dedicated fallback path for hosts guard"
assert_order "$WRAPPER_FILE" 'if ! check_and_handle_db_lock "$@"; then' 'if should_use_wrapper_guard_lib_fallback "$@"; then' \
"wrapper checks pacman db lock before any manual hosts unlock fallback"
assert_contains "$WRAPPER_FILE" 'manual_guard_lib_fallback=1' \
"wrapper tracks whether manual hosts guard fallback was used"
assert_contains "$WRAPPER_FILE" '/etc/pacman.d/hooks/10-guard-lib-unlock-all.hook' \
"wrapper's guard-lib detection checks the generic unlock-all hook"
assert_contains "$WRAPPER_FILE" '/etc/pacman.d/hooks/90-guard-lib-relock-all.hook' \
"wrapper's guard-lib detection checks the generic relock-all hook"
echo "All hosts guard pacman integration regression tests passed."

View File

@ -1,31 +0,0 @@
#!/bin/bash
# Regression checks for makepkg_capped wrapper.
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd)
WRAPPER="$REPO_DIR/periodic_background/digital_wellbeing/pacman/makepkg_capped.sh"
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
printf 'Checking makepkg_capped exists...\n'
[[ -f "$WRAPPER" ]] || fail 'makepkg_capped.sh is missing'
printf 'Checking makepkg_capped syntax...\n'
bash -n "$WRAPPER"
printf 'Checking systemd scope limits are present...\n'
grep -Fq 'CPUQuota=' "$WRAPPER" || fail 'CPUQuota setting missing'
grep -Fq 'MemoryMax=' "$WRAPPER" || fail 'MemoryMax setting missing'
grep -Fq 'MemorySwapMax=0' "$WRAPPER" || fail 'MemorySwapMax=0 setting missing'
grep -Fq 'TasksMax=' "$WRAPPER" || fail 'TasksMax setting missing'
printf 'Checking graceful fallback path exists...\n'
grep -Fq 'run_makepkg_fallback' "$WRAPPER" || fail 'fallback function missing'
grep -Fq 'systemd-run --user --scope --quiet true' "$WRAPPER" || fail 'user-scope probe missing'
printf 'makepkg_capped regression checks passed.\n'

View File

@ -1,126 +0,0 @@
#!/bin/bash
# Tests for the makepkg wrapper + shared stale-lock library.
#
# Covers: script syntax, structural invariants (fail-open source guard,
# FAKEROOTKEY bypass, .orig-refresh, integrity), and the actual stale-lock
# behaviour exercised against a TEMP lock file (never the real system lock).
set -u
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PAC_DIR="$SCRIPT_DIR/../periodic_background/digital_wellbeing/pacman"
LIB="$PAC_DIR/pacman_lock_lib.sh"
WRAPPER="$PAC_DIR/makepkg_wrapper.sh"
INSTALLER="$PAC_DIR/install_makepkg_wrapper.sh"
REWRAP="$PAC_DIR/rewrap_pkg_managers.sh"
HOOK="$PAC_DIR/96-restore-pkg-wrappers.hook"
pass=0
fail=0
ok() {
echo "✓ $1"
pass=$((pass + 1))
}
bad() {
echo "✗ $1"
fail=$((fail + 1))
}
echo "=== Testing makepkg wrapper + shared stale-lock library ==="
# --- Syntax ---------------------------------------------------------------
for f in "$LIB" "$WRAPPER" "$INSTALLER" "$REWRAP"; do
if bash -n "$f"; then ok "syntax valid: $(basename "$f")"; else bad "syntax error: $(basename "$f")"; fi
done
# --- Structural invariants ------------------------------------------------
if grep -q 'PACMAN_LOCK_FILE:-/var/lib/pacman/db.lck' "$LIB"; then
ok "lib lock path is overridable for tests (defaults to real path)"
else bad "lib does not parameterise PACMAN_LOCK_FILE"; fi
if grep -q 'pacman_process_running' "$LIB"; then
ok "lib has cross-user pgrep guard (pacman_process_running)"
else bad "lib missing cross-user pgrep guard"; fi
if grep -q 'FAKEROOTKEY' "$WRAPPER"; then
ok "wrapper has FAKEROOTKEY bypass fast-path"
else bad "wrapper missing FAKEROOTKEY bypass"; fi
# Fail-open: a missing lib must exec real makepkg rather than break it.
if awk '/-r "\$_LIB_DIR\/pacman_lock_lib.sh"/{g=1} g&&/exec "\$MAKEPKG_BIN"/{seen=1} END{exit !seen}' "$WRAPPER"; then
ok "wrapper fails OPEN when lib missing (execs real makepkg)"
else bad "wrapper does not fail open on missing lib"; fi
if grep -q 'if \[ ! -L /usr/bin/makepkg \]' "$INSTALLER"; then
ok "installer refreshes makepkg.orig only when not our symlink (no exec loop)"
else bad "installer .orig guard missing/incorrect"; fi
if grep -q 'When = PostTransaction' "$HOOK" && grep -q 'rewrap_pkg_managers.sh' "$HOOK"; then
ok "survival hook is PostTransaction and runs rewrap helper"
else bad "survival hook malformed"; fi
# Invalid alpm Operation values abort EVERY pacman transaction (alpm parses all
# hooks up front), so the hook must use only Install/Upgrade/Remove.
if grep -E '^Operation' "$HOOK" | grep -qvE '^Operation = (Install|Upgrade|Remove)$'; then
bad "hook has an invalid alpm Operation value (would break all pacman transactions)"
else ok "hook uses only valid alpm Operation values"; fi
# --- Functional (temp lock, no root) --------------------------------------
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"; jobs -p | xargs -r kill 2>/dev/null || true' EXIT
LOCK="$TMP/db.lck"
STUB="$TMP/makepkg.stub"
printf '#!/bin/bash\nprintf "%%s" "$*" > "%s/args"\n' "$TMP" >"$STUB"
chmod +x "$STUB"
# Source the lib once. check_and_handle_db_lock reads $PACMAN_LOCK_FILE at call
# time, so pointing it at the temp lock before sourcing is enough for all cases.
export PACMAN_LOCK_FILE="$LOCK"
# shellcheck source=../periodic_background/digital_wellbeing/pacman/pacman_lock_lib.sh
source "$LIB"
# orphaned + --noconfirm -> cleared
: >"$LOCK"
check_and_handle_db_lock --noconfirm >/dev/null 2>&1 || true
if [[ ! -e $LOCK ]]; then ok "orphaned lock + --noconfirm cleared"; else bad "orphaned lock not cleared"; fi
# absent -> no-op, returns 0
if check_and_handle_db_lock --noconfirm >/dev/null 2>&1; then ok "absent lock is a no-op"; else bad "absent lock returned non-zero"; fi
# old (>=600s) -> auto-removed without --noconfirm
: >"$LOCK"
touch -d '2 hours ago' "$LOCK"
check_and_handle_db_lock >/dev/null 2>&1 || true
if [[ ! -e $LOCK ]]; then ok "old orphaned lock auto-removed without --noconfirm"; else bad "age-based removal failed"; fi
# running 'pacman' process -> removal blocked (cross-user pgrep guard)
: >"$LOCK"
FAKE="$TMP/pacman"
cp /bin/sleep "$FAKE"
setsid "$FAKE" 20 &
fp=$!
sleep 0.3
if check_and_handle_db_lock --noconfirm >/dev/null 2>&1; then blocked=0; else blocked=1; fi
if [[ $blocked -eq 1 && -e $LOCK ]]; then ok "running 'pacman' process blocks removal"; else bad "pgrep guard failed"; fi
kill "$fp" 2>/dev/null || true
sleep 0.2
rm -f "$LOCK"
# wrapper: non-install invocation bypasses, leaves lock, execs real makepkg
: >"$LOCK"
MAKEPKG_BIN="$STUB" bash "$WRAPPER" --version >/dev/null 2>&1 || true
if [[ -e $LOCK && "$(cat "$TMP/args" 2>/dev/null)" == "--version" ]]; then ok "wrapper: non-install bypasses, lock untouched"; else bad "wrapper non-install bypass failed"; fi
# wrapper: -sif clears orphaned lock then execs real makepkg
: >"$LOCK"
MAKEPKG_BIN="$STUB" bash "$WRAPPER" -sif --noconfirm >/dev/null 2>&1 || true
if [[ ! -e $LOCK && "$(cat "$TMP/args" 2>/dev/null)" == "-sif --noconfirm" ]]; then ok "wrapper: -sif clears orphaned lock then execs makepkg"; else bad "wrapper install pre-flight failed"; fi
# wrapper: FAKEROOTKEY (build sandbox) bypasses, keeps lock
: >"$LOCK"
MAKEPKG_BIN="$STUB" FAKEROOTKEY=deadbeef bash "$WRAPPER" -sif --noconfirm >/dev/null 2>&1 || true
if [[ -e $LOCK ]]; then ok "wrapper: FAKEROOTKEY bypass keeps lock"; else bad "wrapper fakeroot bypass failed"; fi
echo ""
echo "=== Result: $pass passed, $fail failed ==="
if [[ $fail -eq 0 ]]; then echo "=== All Tests Passed! ==="; else exit 1; fi

View File

@ -1,189 +0,0 @@
#!/bin/bash
# Regression tests for the music parallelism daemon's polling cadence.
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd)
TARGET_SCRIPT="$REPO_DIR/periodic_background/digital_wellbeing/music_parallelism.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"
# Mirror the script's real position inside the worktree instead of hardcoding
# it. music_parallelism.sh sources its library as $SCRIPT_DIR/../lib/common.sh,
# so the copy must sit at the same depth below the repo root or that path
# resolves outside the worktree. When it did, the script silently fell back to
# the real /usr/local/lib/common.sh and the stub below was never used — the
# tests then measured the live system and failed as if the daemon were wrong.
# Deriving the path keeps this correct across future reorganisations.
TARGET_REL="${TARGET_SCRIPT#"$REPO_DIR"/}"
if [[ "$TARGET_REL" == "$TARGET_SCRIPT" ]]; then
fail "TARGET_SCRIPT ($TARGET_SCRIPT) is not below REPO_DIR ($REPO_DIR)"
fi
WORKTREE_SCRIPT="$WORKTREE/$TARGET_REL"
# The library lives at <repo>/lib/common.sh; keep it relative to the
# same root rather than assuming how deep the script itself is nested.
WORKTREE_LIB="$WORKTREE/lib/common.sh"
mkdir -p "$(dirname "$WORKTREE_SCRIPT")" "$(dirname "$WORKTREE_LIB")" "$BIN_DIR"
cp "$TARGET_SCRIPT" "$WORKTREE_SCRIPT"
# The script sources its own libs from $SCRIPT_DIR/lib/. They must travel into
# the worktree beside it, or every source line fails and the script exits before
# any behaviour under test runs.
cp -r "$(dirname "$TARGET_SCRIPT")/lib" "$(dirname "$WORKTREE_SCRIPT")/lib"
# Fail loudly if the copy cannot reach the stub, rather than letting the script
# fall through to the real library and produce a misleading behavioural failure.
#
# The relative prefix is read out of the script rather than written here. It is
# a function of how deep the script sits below the repo root, so the flattening
# that moved it from scripts/periodic_background/... up to
# periodic_background/... changed it from ../lib to ../../lib, and a hardcoded
# copy of it fails the moment the tree is reorganised again.
# The source line names SCRIPT_DIR literally; matching it means matching a
# dollar sign as data. Spelling that byte as \x24 keeps the pattern free of a
# literal $ so it reads unambiguously as text rather than an expansion.
SOURCE_REL="$(sed -n "s|.*source \"$(printf '\\x24')SCRIPT_DIR/\(\.\./[^\"]*lib/common\.sh\)\".*|\1|p" "$TARGET_SCRIPT" | head -1)"
if [[ -z "$SOURCE_REL" ]]; then
fail "cannot determine how $TARGET_SCRIPT sources lib/common.sh"
fi
STUB_FROM_SCRIPT="$(dirname "$WORKTREE_SCRIPT")/$SOURCE_REL"
if [[ "$(realpath -m "$STUB_FROM_SCRIPT")" != "$(realpath -m "$WORKTREE_LIB")" ]]; then
fail "worktree layout drift: script at $WORKTREE_SCRIPT resolves $SOURCE_REL to $(realpath -m "$STUB_FROM_SCRIPT"), not the stub at $WORKTREE_LIB"
fi
cat >"$WORKTREE_LIB" <<'EOF'
#!/bin/bash
# Records that the stub — not the real installed common.sh — was sourced.
if [[ -n ${MUSIC_PARALLELISM_STUB_MARKER:-} ]]; then
printf 'sourced\n' >"$MUSIC_PARALLELISM_STUB_MARKER"
fi
FOCUS_APPS_WINDOWS=("Mock Focus App")
FOCUS_APPS_PROCESSES=("mock-focus-proc")
is_focus_app_running() {
if [[ ${MOCK_FOCUS_ACTIVE:-0} -eq 1 ]]; then
printf '%s\n' "Mock Focus App"
return 0
fi
return 1
}
get_timestamp() {
printf '%s\n' "${MOCK_TIMESTAMP:-1000}"
}
log_message() {
:
}
EOF
chmod +x "$WORKTREE_LIB"
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_wait="$1"
local focus_active="$2"
local music_proc_name="${3:-}"
local mode="${4:-instant}"
local wait_log="$TMP_DIR/wait.log"
local proc_root="$TMP_DIR/proc"
local stub_marker="$TMP_DIR/stub.marker"
: >"$wait_log"
rm -f "$stub_marker"
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
PATH="$BIN_DIR:$PATH" \
MUSIC_PARALLELISM_TEST_WAIT_LOG="$wait_log" \
MUSIC_PARALLELISM_TEST_EXIT_AFTER_WAIT=1 \
MUSIC_PARALLELISM_STUB_MARKER="$stub_marker" \
XDOTOOL_LOG="${XDOTOOL_LOG:-}" \
PROC_ROOT="$proc_root" \
MOCK_FOCUS_ACTIVE="$focus_active" \
bash "$WORKTREE_SCRIPT" "$mode" \
>/dev/null 2>&1 || true
# Without this the script can source the real /usr/local/lib/common.sh and
# still "pass" or fail for reasons that have nothing to do with the daemon.
[[ -f "$stub_marker" ]] ||
fail 'stubbed common.sh was not sourced — the script used a different library, so this run measured the live system rather than the mock'
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
printf 'Checking conflict handling uses the faster retry interval...\n'
run_case 5 1 spotify
printf 'Checking idle mode uses the idle interval...\n'
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'

Some files were not shown because too many files have changed in this diff Show More