mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:27:03 +01:00
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:
parent
981823e3b9
commit
6b828d29aa
10
.github/workflows/shell-tests.yml
vendored
10
.github/workflows/shell-tests.yml
vendored
@ -27,7 +27,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
tests=(
|
||||
test_i3blocks_persist_common.sh
|
||||
test_music_parallelism.sh
|
||||
test_setup_dns_blocker.sh
|
||||
)
|
||||
failed=0
|
||||
@ -124,7 +123,9 @@ jobs:
|
||||
|
||||
- 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`.
|
||||
#
|
||||
# test_security_hardening.sh inspects a configured machine (chattr flags,
|
||||
@ -136,13 +137,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tests=(
|
||||
test_hosts_guard_pacman_integration.sh
|
||||
test_i3blocks_efficiency.sh
|
||||
test_makepkg_capped.sh
|
||||
test_makepkg_wrapper.sh
|
||||
test_pacman_wrapper_security.sh
|
||||
test_security_hardening.sh
|
||||
test_shutdown_timer_monitor.sh
|
||||
)
|
||||
failed=0
|
||||
for t in "${tests[@]}"; do
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -381,7 +381,6 @@ bottles/
|
||||
|
||||
# archwiki_rag local vector index: regenerated by `reindex`, machine-specific,
|
||||
# and full of binary sqlite/parquet blobs.
|
||||
linux_configuration/periodic_background/digital_wellbeing/data/
|
||||
|
||||
# ===========================================================================
|
||||
# billsplit (Flutter) — launcher icons & platform assets MUST live in-repo
|
||||
|
||||
@ -34,12 +34,7 @@ repos:
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
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
|
||||
exclude: ^linux_configuration/periodic_background/digital_wellbeing/setup_midnight_shutdown\.sh$
|
||||
- id: check-yaml
|
||||
args: [--unsafe]
|
||||
- id: check-json
|
||||
@ -87,15 +82,14 @@ repos:
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: directory-depth-cap
|
||||
name: Report paths over the directory-depth cap
|
||||
# Repo-wide, and deliberately --warn for now: 47 paths under
|
||||
# periodic_background/{hosts,digital_wellbeing} are over the cap and
|
||||
# can only come down by extracting those two units, which is blocked
|
||||
# on a manual guard repoint. Running it warn-only keeps the number
|
||||
# visible on every commit without blocking unrelated work. Drop
|
||||
# --warn once both units are out; that is the last step of the
|
||||
# extraction, not a separate decision.
|
||||
entry: bash meta/scripts/check_directory_depth.sh --warn --all
|
||||
name: Enforce the directory-depth cap
|
||||
# Repo-wide and ENFORCING. It ran --warn while the 46 paths under
|
||||
# periodic_background/{hosts,digital_wellbeing} were over the cap;
|
||||
# both units are now their own repos (kuhyx/hosts-blocker,
|
||||
# kuhyx/digital-wellbeing) and the count is 0, so the cap is real.
|
||||
# Re-adding --warn would silently re-open the hole it was there to
|
||||
# close: put the offending path somewhere legal instead.
|
||||
entry: bash meta/scripts/check_directory_depth.sh --all
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
@ -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
|
||||
`python_pkg/focus_policy`),
|
||||
[`hosts-blocker`](https://github.com/kuhyx/hosts-blocker) (lives at
|
||||
`~/hosts-blocker`; monorepo callers resolve it via
|
||||
`linux_configuration/lib/extracted_repos.sh`).
|
||||
`~/hosts-blocker`),
|
||||
[`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).
|
||||
|
||||
## Adding a Flutter app
|
||||
|
||||
@ -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."
|
||||
}
|
||||
@ -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."
|
||||
]
|
||||
}
|
||||
@ -81,8 +81,16 @@ fi
|
||||
# --threshold 2: fail if more than 2% duplication
|
||||
# Excluded from the scan, and why:
|
||||
# .venv/ vendored third-party shell (virtualenv, tqdm completions).
|
||||
# .ci-mirror-venv/
|
||||
# Not ours, and it alone pushed the repo over the bar: the
|
||||
# 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
|
||||
# stop duplicated production logic, not shared test setup.
|
||||
# Needed headroom for the Phase 3 coverage campaign.
|
||||
@ -92,7 +100,7 @@ jscpd_output=$("$JSCPD_BIN" \
|
||||
--min-tokens 25 \
|
||||
--threshold 2 \
|
||||
--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=$?
|
||||
|
||||
if [[ ${jscpd_exit:-0} -ne 0 ]]; then
|
||||
|
||||
@ -179,7 +179,7 @@ fi
|
||||
cd ~/linux-configuration
|
||||
sudo "$HOME/hosts-blocker/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
|
||||
sudo features/setup_activitywatch.sh
|
||||
sudo utils/setup_media_organizer.sh
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
#
|
||||
# SECONDARY modules (prompted unless --all / --none given):
|
||||
# 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/
|
||||
# 7. Compulsive opening block – block_compulsive_opening.sh
|
||||
#
|
||||
@ -135,7 +135,7 @@ run_installer "Hosts blocking" \
|
||||
bash "$(extracted_repo_dir hosts-blocker)/install.sh"
|
||||
|
||||
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)
|
||||
@ -146,13 +146,13 @@ ask_install "Steam backlog enforcer" \
|
||||
sudo bash "$REPO_ROOT/python_pkg/steam_backlog_enforcer/install.sh"
|
||||
|
||||
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" \
|
||||
bash "$LINUX_CONFIG/i3/install.sh"
|
||||
|
||||
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
|
||||
|
||||
@ -40,7 +40,9 @@ set -Eeuo pipefail
|
||||
|
||||
SCRIPT_NAME=${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"
|
||||
|
||||
UBLOCK_EXT_PATH="/usr/share/chromium/extensions/ublock-origin"
|
||||
|
||||
@ -57,38 +57,41 @@ NC=$'\033[0m' # No Color
|
||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
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
|
||||
PACMAN_WRAPPER_INSTALL="$CONFIG_DIR/periodic_background/digital_wellbeing/pacman/install_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"
|
||||
# Drift manifests written by the two installers above (see deployment_drift).
|
||||
PACMAN_WRAPPER_MANIFEST="/var/lib/pacman-wrapper/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"
|
||||
STARTUP_MONITOR_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/setup_pc_startup_monitor.sh"
|
||||
MIDNIGHT_SHUTDOWN_SCRIPT="$DW_REPO/setup_midnight_shutdown.sh"
|
||||
STARTUP_MONITOR_SCRIPT="$DW_REPO/setup_pc_startup_monitor.sh"
|
||||
PERIODIC_SYSTEM_SCRIPT="$CONFIG_DIR/periodic_background/setup_periodic_system.sh"
|
||||
# hosts-blocker was EXTRACTED (github.com/kuhyx/hosts-blocker). Resolved
|
||||
# below, after REAL_HOME is computed, for the same reason screen-locker is.
|
||||
HOSTS_INSTALL_SCRIPT=""
|
||||
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"
|
||||
LEECHBLOCK_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/install_leechblock.sh"
|
||||
REMOVE_GUEST_MODE_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/remove_guest_mode.sh"
|
||||
VBOX_HOSTS_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh"
|
||||
COMPULSIVE_BLOCK_SCRIPT="$DW_REPO/block_compulsive_opening.sh"
|
||||
LEECHBLOCK_SCRIPT="$DW_REPO/install_leechblock.sh"
|
||||
REMOVE_GUEST_MODE_SCRIPT="$DW_REPO/remove_guest_mode.sh"
|
||||
VBOX_HOSTS_SCRIPT="$DW_REPO/virtualbox/enforce_vbox_hosts.sh"
|
||||
# screen-locker was EXTRACTED out of this monorepo into its own repo
|
||||
# (github.com/kuhyx/screen-locker, checked out at ~/screen-locker), so these
|
||||
# paths deliberately live outside testsAndMisc. They used to point at
|
||||
# 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
|
||||
# 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_INSTALL_SCRIPT="$WORKOUT_LOCKER_REPO/install_systemd.sh"
|
||||
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"
|
||||
|
||||
# A MISSING repair script means THIS TOOL is broken: it silently stops repairing
|
||||
|
||||
@ -1 +0,0 @@
|
||||
node_modules/
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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 "$@"
|
||||
@ -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
|
||||
@ -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"
|
||||
@ -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
@ -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."
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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" "$@"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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})"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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 ""
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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 ]]
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
@ -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"
|
||||
@ -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 ""
|
||||
@ -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 ]]
|
||||
}
|
||||
@ -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 ]]
|
||||
}
|
||||
@ -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"
|
||||
@ -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"
|
||||
@ -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"
|
||||
@ -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"
|
||||
@ -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
|
||||
```
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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}"
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
@ -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.
|
||||
@ -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 "$@"
|
||||
@ -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" "$@"
|
||||
@ -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 "$@"
|
||||
@ -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
|
||||
@ -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.
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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 "$@"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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
|
||||
@ -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 "$@"
|
||||
@ -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
|
||||
@ -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 "$@"
|
||||
@ -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
|
||||
}
|
||||
@ -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" "$@"
|
||||
@ -17,9 +17,10 @@ init_setup_script "Periodic System Setup - Pacman Wrapper & Hosts File" "$@"
|
||||
CONFIG_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
|
||||
# Define paths
|
||||
PACMAN_WRAPPER_SCRIPT="$CONFIG_DIR/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh"
|
||||
PACMAN_WRAPPER_INSTALL="$CONFIG_DIR/periodic_background/digital_wellbeing/pacman/install_pacman_wrapper.sh"
|
||||
MAKEPKG_WRAPPER_INSTALL="$CONFIG_DIR/periodic_background/digital_wellbeing/pacman/install_makepkg_wrapper.sh"
|
||||
DW_REPO="$(require_extracted_repo digital-wellbeing "the periodic system setup")" || exit 1
|
||||
PACMAN_WRAPPER_SCRIPT="$DW_REPO/pacman/pacman_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
|
||||
# (github.com/kuhyx/hosts-blocker), so this path deliberately lives
|
||||
# outside it. A missing checkout is fatal, never a silent skip.
|
||||
|
||||
@ -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
|
||||
|
||||
}
|
||||
@ -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
|
||||
|
||||
}
|
||||
@ -9,7 +9,7 @@ sec_tests_compulsive_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"
|
||||
|
||||
# The blocker was split into lib/cco_*.sh under the 250-line cap, so these
|
||||
|
||||
@ -23,7 +23,7 @@ sec_tests_pacman_wrapper() {
|
||||
live_section 0 # repository checks from here on
|
||||
|
||||
# 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 grep -qi "google-chrome" "$blocked_file"; then
|
||||
test_result "google-chrome in blocked list" "pass"
|
||||
@ -57,7 +57,7 @@ sec_tests_pacman_wrapper() {
|
||||
live_section 0
|
||||
|
||||
# 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 grep -q "auto_install_leechblock" "$wrapper_file"; then
|
||||
test_result "LeechBlock auto-install function exists" "pass"
|
||||
|
||||
@ -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."
|
||||
@ -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'
|
||||
@ -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
|
||||
@ -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
Loading…
Reference in New Issue
Block a user