Fail the build on any shellcheck finding; fix the 222 the old gate hid
Some checks failed
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
billsplit CI / quality (push) Has been cancelled
reverse-survivors CI / check (push) Has been cancelled

The shellcheck hook ran with --severity=warning, so info- and style-level
findings never failed a commit. 222 had accumulated across 94 of 244 shell
files. They were not all style noise -- six were bugs:

  - Five scripts in single_use/misc/testsAndMisc-bash/ sourced
    $SCRIPT_DIR/../../lib/common.sh, one directory too shallow. Nothing exists
    at that path, so all five failed at runtime.
  - setup_personal_website.sh and setup_kcd2_dice_solver.sh called usage()
    (which ends in `exit 0`) and then `exit 1` for an unknown command. The
    exit 1 was unreachable, so a typo'd subcommand reported SUCCESS.
  - Six i3blocks scripts had a `# shellcheck source=` directive missing the
    scripts/periodic_background/ path segment. The stale path had silently
    disabled source-following for all of them.
  - diagnose_arch_performance.sh word-split a newline-joined file list; when
    the find came back empty, awk got no file operands, fell through to
    reading stdin, and the diagnostic hung.
  - lib/common.sh's handle_arg_help_or_unknown ended in an unreachable
    `return 0` while documenting "Returns: 0 if handled" -- a contract no code
    path could satisfy.
  - deploy.sh's SCRIPT_DIR is reassigned by config.sh, which it sources at top
    level, and config.sh can repoint it at the on-device path
    /data/local/tmp/focus_mode. Renamed to DEPLOY_DIR.

The SC1091 bulk (81 findings) was resolved rather than silenced: .shellcheckrc
sets external-sources + source-path=SCRIPTDIR so sourced files are actually
followed and analysed. That is what surfaced the i3blocks and deploy.sh bugs.

17 `# shellcheck disable=` directives remain, each with a written reason, and
only where the finding is provably wrong: injection-test payloads that must not
expand, literal source text being grepped for, POSIX sh scripts on the phone
where arrays do not exist, and source paths that only exist at runtime.
Everything else was fixed in code.

Verified in a CLEAN worktree, not just my own tree. config.sh sources the
gitignored config_secrets.sh, which exists locally but not in a fresh checkout;
a working-tree-only check passed while ci_mirror.sh -- which lints HEAD in a
clean worktree, exactly as CI does -- failed on it.

The gate is verified to bite: a file whose only finding is SC2016 (info) fails
the hook, while the old --severity=warning gate exits 0 on it.

third_party/ is excluded -- vendored upstream, matching the prettier hook.

Not fixed, reported: check_polling_antipatterns.sh carries a pre-existing
SC2034 disable that hides a real bug -- its `patterns` array is never used, so
the checker detects nothing. Confirmed unchanged by this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-03 18:34:49 +02:00
parent 79829ec8a2
commit b83ce5d3c7
48 changed files with 5441 additions and 5081 deletions

View File

@ -412,13 +412,25 @@ repos:
hooks: hooks:
- id: shellcheck - id: shellcheck
name: shellcheck name: shellcheck
entry: bash -c 'printf "%s\0" "$@" | xargs -0 -n 40 shellcheck --severity=warning' -- # NO --severity filter: every finding shellcheck reports, down to info
# and style, fails the commit. The previous --severity=warning quietly
# accepted 222 findings across 94 of 244 shell files -- and among them
# were real defects, not noise: two scripts that reported SUCCESS on an
# unknown command, five that sourced a path off by one directory (so
# they failed at runtime), a stale `source=` directive that had silently
# disabled following for six i3blocks scripts, and a diagnostic that
# would hang on stdin when its file list came back empty.
# Settings live in .shellcheckrc (external-sources, source-path).
entry: bash -c 'printf "%s\0" "$@" | xargs -0 -n 40 shellcheck' --
language: system language: system
types: [shell] types: [shell]
# shellcheck only understands sh/bash/dash/ksh; it cannot parse zsh # shellcheck only understands sh/bash/dash/ksh; it cannot parse zsh
# syntax (e.g. extended globs like (#s)). zsh files are checked by the # syntax (e.g. extended globs like (#s)). zsh files are checked by the
# zsh-syntax hook below instead. # zsh-syntax hook below instead.
exclude_types: [zsh] exclude_types: [zsh]
# Vendored upstream code: we do not own it and patches here would be
# lost on the next vendor sync. Matches the prettier hook's exclusion.
exclude: ^third_party/
# =========================================================================== # ===========================================================================
# ZSH SYNTAX - zsh -n parse check (shellcheck has no zsh mode) # ZSH SYNTAX - zsh -n parse check (shellcheck has no zsh mode)

20
.shellcheckrc Normal file
View File

@ -0,0 +1,20 @@
# Repo-wide shellcheck configuration.
#
# The pre-commit hook runs shellcheck with NO --severity filter, so every
# finding shellcheck reports - including info- and style-level - fails the
# commit. Previously the hook gated at --severity=warning, which silently
# accepted 222 findings across 94 of 244 shell files.
#
# Follow `source`d files so SC1091 can be satisfied by pointing at the real
# file rather than silenced. Where a script sources a path that only exists at
# runtime (e.g. /usr/local/bin/pacman_lock_lib.sh, installed by its installer),
# the call site carries an explicit `# shellcheck source=<repo path>` directive
# naming the in-repo original, so the sourced code is genuinely checked.
external-sources=true
# Resolve `source` targets relative to the sourcing script's own directory.
# Nearly every site here is `source "$SCRIPT_DIR/../../lib/common.sh"`, and
# shellcheck cannot expand $SCRIPT_DIR - but with SCRIPTDIR on the search path a
# `# shellcheck source=../../lib/common.sh` directive at the call site resolves
# to the real file, so the sourced code is actually analysed instead of skipped.
source-path=SCRIPTDIR

View File

@ -0,0 +1,18 @@
{
"title": "Gate shellcheck on every finding, and fix all 222 that the old gate hid",
"objective": "The pre-commit shellcheck hook ran with --severity=warning, so info- and style-level findings never failed a commit. 222 of them had accumulated across 94 of 244 shell files, and several were real defects rather than style noise. Make the hook fail on ANY shellcheck finding and drive the repo to zero, fixing the underlying code rather than suppressing it.",
"acceptance_criteria": [
"shellcheck reports zero findings, at full strictness, across every tracked first-party .sh file",
"The pre-commit hook runs shellcheck with no --severity filter",
"The hook demonstrably FAILS on an info-level-only finding and passes on the clean tree",
"Sourced files are genuinely followed (external-sources + source-path) rather than SC1091 being silenced",
"Every remaining `# shellcheck disable=` carries a written reason at the call site",
"Behaviour is preserved: scripts that were runnable before still run, and tests that covered them still pass"
],
"out_of_scope": [
"third_party/ — vendored upstream code we do not own; excluded from the hook, matching the existing prettier exclusion",
"The pre-existing SC2034 disable in check_polling_antipatterns.sh, which hides a real bug (its `patterns` array is never used, so the checker checks nothing). Reported, not fixed — it is a behavioural change beyond a lint sweep.",
"linux_configuration/scripts/single_use/fresh-install/main.sh — another session's uncommitted work, deliberately left unstaged"
],
"verifier": "pre-commit run shellcheck --all-files (exit 0); the same hook against a deliberately planted info-level canary (exit 1); per-file `bash -n`/`sh -n`; and re-running the affected test suites"
}

View File

@ -0,0 +1,69 @@
{
"intent": "Make the shellcheck pre-commit hook fail on every finding, and clear all 222 findings the previous --severity=warning gate had been accepting silently.",
"scope": [
".pre-commit-config.yaml (hook strictness + third_party exclusion), .shellcheckrc (new)",
"45 shell files across linux_configuration/, phone_focus_mode/, meta/",
"NON-GOAL: third_party/ vendored code",
"NON-GOAL: linux_configuration/scripts/single_use/fresh-install/main.sh (another session's uncommitted work, left unstaged)"
],
"changes": [
"Added .shellcheckrc with external-sources=true and source-path=SCRIPTDIR. This RESOLVES sourced files instead of silencing SC1091: 81 -> 13 findings with no suppression, and it made shellcheck analyse the sourced code, which is what surfaced several of the bugs below.",
"Removed --severity=warning from the hook so info- and style-level findings fail the commit; excluded third_party/ (vendored, matching the prettier hook).",
"REAL BUG: five scripts in single_use/misc/testsAndMisc-bash/ sourced $SCRIPT_DIR/../../lib/common.sh, one directory too shallow. The file does not exist at that path, so every one of them failed at runtime. Corrected to ../../../lib/common.sh.",
"REAL BUG: setup_personal_website.sh and setup_kcd2_dice_solver.sh called usage() (which ends in `exit 0`) and then `exit 1` on an unknown command. The exit 1 was unreachable, so a typo'd subcommand reported SUCCESS. usage() now takes an exit status; both verified to exit 1.",
"REAL BUG: six i3blocks scripts carried a `# shellcheck source=linux_configuration/i3-configuration/...` directive missing the scripts/periodic_background/ segment. The stale path silently disabled source-following. Corrected to a script-relative `source=persist_common.sh`.",
"REAL BUG: diagnose_arch_performance.sh word-split a newline-joined `gov_files` string; when the find returned nothing, awk was left with no file operands and fell through to reading stdin, hanging the diagnostic. Now a mapfile array with an explicit empty guard.",
"REAL BUG: lib/common.sh's handle_arg_help_or_unknown ended with an unreachable `return 0` while its doc promised 'Returns: 0 if handled' — a contract no code path could satisfy. Dead line removed, doc corrected.",
"phone_focus_mode/deploy.sh: renamed its SCRIPT_DIR to DEPLOY_DIR. config.sh is sourced at top level and reassigns SCRIPT_DIR, and can repoint it at the on-device path /data/local/tmp/focus_mode — after which every later path in deploy.sh would resolve against the wrong tree.",
"Mechanical but genuine fixes elsewhere: printf format strings (SC2059) with colour variables moved to %s arguments — which required switching the colour definitions to ANSI-C $'...' quoting, since printf does not interpret backslash escapes in arguments; `A && B || C` rewritten as if/then/else; ls-parsing replaced with find/glob arrays; unquoted expansions quoted; repeated appends grouped."
],
"verification": [
{
"command": "shellcheck (full strictness) over every tracked first-party *.sh, in a CLEAN worktree",
"result": "pass",
"evidence": "222 findings in 94 files -> 0 findings in 0 files. Verified with `git worktree add --detach` on HEAD rather than the working tree: config.sh sources the gitignored config_secrets.sh, which exists locally but NOT in a clean checkout, so a working-tree-only check passed while ci_mirror.sh (which lints HEAD in a clean worktree) failed."
},
{
"command": "pre-commit run shellcheck --all-files",
"result": "pass",
"evidence": "exit 0"
},
{
"command": "pre-commit run shellcheck --files <planted info-level canary>",
"result": "pass",
"evidence": "exit 1 on a file whose ONLY finding is SC2016 (info). The old --severity=warning gate exits 0 on the same file - this is the regression that let 222 findings accumulate."
},
{
"command": "bash phone_focus_mode/lib/tests/test_tether_enforcer.sh",
"result": "pass",
"evidence": "17 passed, 0 failed after rewriting all 17 assertions from `cond && ok || no` to if/then/else"
},
{
"command": "bash phone_focus_mode/lib/tests/test_adb_common.sh",
"result": "pass",
"evidence": "13 passed, 0 failed"
},
{
"command": "bash linux_configuration/tests/test_shutdown_timer_monitor.sh",
"result": "pass",
"evidence": "regression checks passed"
},
{
"command": "behavioural spot-checks",
"result": "pass",
"evidence": "setup_personal_website.sh bogus -> exit 1 (was 0), --help -> 0; disk_cleanup_check.sh output byte-identical with ANSI escapes present and zero literal \\033 leaked; build.sh picks the same build-tools dir as the old ls pipeline; every touched file passes bash -n / sh -n"
}
],
"risks": [
"The gate is now strict for everyone: any new shell code must be shellcheck-clean at info level. That is the intent, but it will fail commits that previously passed.",
"16 `# shellcheck disable=` directives were added where the finding is provably a false positive - injection-test payloads that must not expand, literal source text being grepped for, POSIX sh scripts on the phone where arrays do not exist, and runtime-only source paths. Each carries a written reason. They are the only suppressions; everything else was fixed in code.",
"A comment line beginning '# shellcheck ' is parsed as a directive - one such comment broke a file mid-sweep and had to be reworded. Worth knowing when writing these reasons.",
"Verifying a lint gate against the WORKING TREE is not the same as verifying it against a clean checkout. config.sh sources a gitignored secrets file that exists locally; shellcheck resolved it here and reported nothing, while ci_mirror.sh - which checks HEAD out into a clean worktree, exactly as CI does - failed. Any future gate work must be checked the ci_mirror way.",
"check_polling_antipatterns.sh has a pre-existing SC2034 disable hiding a real bug: its `patterns` array is never used, so the checker detects nothing. Confirmed against the pre-change version - behaviour is unchanged by this commit. Left for a separate fix."
],
"rollback": [
"Restore --severity=warning in the shellcheck hook entry and delete .shellcheckrc to return to the old gate",
"git revert the commit to restore all 45 files; the code fixes are independent of the hook change and can be kept separately",
"After rollback, validate: pre-commit run shellcheck --all-files still passes, and the affected test suites still pass"
]
}

View File

@ -116,8 +116,11 @@ parse_interactive_args() {
# Handle common argument patterns for scripts with custom usage functions # Handle common argument patterns for scripts with custom usage functions
# Usage: handle_arg_help_or_unknown "$1" usage_function err_function # Usage: handle_arg_help_or_unknown "$1" usage_function err_function
# Returns: 0 if argument was handled (caller should continue), 1 if not our concern # Returns: 1 if the argument is not ours, so the caller handles it.
# Exits: on -h/--help (exit 0) or unknown arg starting with - (exit 2) # Exits: on -h/--help (exit 0) or unknown arg starting with - (exit 2).
# There is deliberately no success return: every other branch exits. The old
# trailing `return 0` was unreachable, and the doc promised a 0 that no code
# path could ever produce.
handle_arg_help_or_unknown() { handle_arg_help_or_unknown() {
local arg="$1" local arg="$1"
local usage_fn="${2:-usage}" local usage_fn="${2:-usage}"
@ -137,7 +140,6 @@ handle_arg_help_or_unknown() {
return 1 # Not a flag, let caller handle it return 1 # Not a flag, let caller handle it
;; ;;
esac esac
return 0
} }
# Initialize a setup script with common boilerplate # Initialize a setup script with common boilerplate
@ -199,7 +201,7 @@ is_focus_app_running() {
for app in "${FOCUS_APPS_PROCESSES[@]}"; do for app in "${FOCUS_APPS_PROCESSES[@]}"; do
for comm in /proc/[0-9]*/comm; do for comm in /proc/[0-9]*/comm; do
[[ -r $comm ]] || continue [[ -r $comm ]] || continue
read -r _proc_comm < "$comm" 2>/dev/null || continue read -r _proc_comm <"$comm" 2>/dev/null || continue
if [[ $_proc_comm == *"$app"* ]]; then if [[ $_proc_comm == *"$app"* ]]; then
echo "$_proc_comm" echo "$_proc_comm"
return 0 return 0
@ -450,7 +452,7 @@ get_second() {
# Get Unix timestamp from boot (uptime in seconds) # Get Unix timestamp from boot (uptime in seconds)
# Usage: boot_seconds=$(get_uptime_seconds) # Usage: boot_seconds=$(get_uptime_seconds)
get_uptime_seconds() { get_uptime_seconds() {
read -r uptime_with_fraction _ < /proc/uptime read -r uptime_with_fraction _ </proc/uptime
printf '%.*f\n' 0 "$uptime_with_fraction" printf '%.*f\n' 0 "$uptime_with_fraction"
} }

View File

@ -18,12 +18,12 @@ DRY_RUN=0
STATUS_ONLY=0 STATUS_ONLY=0
# Colors # Colors
RED='\033[0;31m' RED=$'\033[0;31m'
GREEN='\033[0;32m' GREEN=$'\033[0;32m'
YELLOW='\033[0;33m' YELLOW=$'\033[0;33m'
BLUE='\033[0;34m' BLUE=$'\033[0;34m'
CYAN='\033[0;36m' CYAN=$'\033[0;36m'
NC='\033[0m' # No Color NC=$'\033[0m' # No Color
# Get script and config directories # Get script and config directories
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
@ -1320,17 +1320,17 @@ main
# it hid dead hosts self-repair for five months. Make it impossible to miss. # it hid dead hosts self-repair for five months. Make it impossible to miss.
###################################################################### ######################################################################
if [[ ${#MISSING_SCRIPTS[@]} -gt 0 ]]; then if [[ ${#MISSING_SCRIPTS[@]} -gt 0 ]]; then
printf "\n${RED}" printf '\n%s' "$RED"
printf '=%.0s' {1..74} printf '=%.0s' {1..74}
printf "\n SELF-REPAIR IS BROKEN — %d repair script(s) MISSING\n" "${#MISSING_SCRIPTS[@]}" printf "\n SELF-REPAIR IS BROKEN — %d repair script(s) MISSING\n" "${#MISSING_SCRIPTS[@]}"
printf '=%.0s' {1..74} printf '=%.0s' {1..74}
printf "${NC}\n" printf '%s\n' "$NC"
for missing in "${MISSING_SCRIPTS[@]}"; do for missing in "${MISSING_SCRIPTS[@]}"; do
err "$missing" err "$missing"
done done
printf "${RED}The services above are NOT being repaired — this tool found the\n" printf '%sThe services above are NOT being repaired — this tool found the\n' "$RED"
printf "problem and then could not run the fix. Correct these paths first.${NC}\n" printf 'problem and then could not run the fix. Correct these paths first.%s\n' "$NC"
printf "${CYAN}Also logged at error priority: journalctl -p err -t check-and-enable-services${NC}\n\n" printf '%sAlso logged at error priority: journalctl -p err -t check-and-enable-services%s\n\n' "$CYAN" "$NC"
exit 2 exit 2
fi fi

View File

@ -16,6 +16,9 @@ if [[ -f "$SCRIPT_DIR/../../lib/common.sh" ]]; then
source "$SCRIPT_DIR/../../lib/common.sh" source "$SCRIPT_DIR/../../lib/common.sh"
elif [[ -f "/usr/local/lib/common.sh" ]]; then elif [[ -f "/usr/local/lib/common.sh" ]]; then
# shellcheck source=/usr/local/lib/common.sh # 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" source "/usr/local/lib/common.sh"
else else
echo "ERROR: common.sh library not found" echo "ERROR: common.sh library not found"
@ -24,7 +27,7 @@ fi
# Configuration # Configuration
LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/music-parallelism" LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/music-parallelism"
mkdir -p "$LOG_DIR" 2> /dev/null || true mkdir -p "$LOG_DIR" 2>/dev/null || true
export LOG_FILE="$LOG_DIR/music-parallelism.log" export LOG_FILE="$LOG_DIR/music-parallelism.log"
CHECK_INTERVAL=15 CHECK_INTERVAL=15
FAST_CHECK_INTERVAL=5 FAST_CHECK_INTERVAL=5
@ -101,7 +104,7 @@ wait_seconds() {
local start_ts end_ts elapsed_s remaining_s local start_ts end_ts elapsed_s remaining_s
if [[ -n ${MUSIC_PARALLELISM_TEST_WAIT_LOG:-} ]]; then if [[ -n ${MUSIC_PARALLELISM_TEST_WAIT_LOG:-} ]]; then
printf '%s\n' "$timeout_s" >> "$MUSIC_PARALLELISM_TEST_WAIT_LOG" printf '%s\n' "$timeout_s" >>"$MUSIC_PARALLELISM_TEST_WAIT_LOG"
if [[ ${MUSIC_PARALLELISM_TEST_EXIT_AFTER_WAIT:-0} -eq 1 ]]; then if [[ ${MUSIC_PARALLELISM_TEST_EXIT_AFTER_WAIT:-0} -eq 1 ]]; then
exit 99 exit 99
fi fi
@ -113,7 +116,7 @@ wait_seconds() {
printf -v end_ts '%(%s)T' -1 printf -v end_ts '%(%s)T' -1
elapsed_s=$((end_ts - start_ts)) elapsed_s=$((end_ts - start_ts))
if (( elapsed_s < timeout_s )); then if ((elapsed_s < timeout_s)); then
remaining_s=$((timeout_s - elapsed_s)) remaining_s=$((timeout_s - elapsed_s))
sleep "$remaining_s" sleep "$remaining_s"
fi fi
@ -124,7 +127,7 @@ contains_music_process() {
for comm_file in "$PROC_ROOT"/[0-9]*/comm; do for comm_file in "$PROC_ROOT"/[0-9]*/comm; do
[[ -r $comm_file ]] || continue [[ -r $comm_file ]] || continue
read -r comm_lower < "$comm_file" || continue read -r comm_lower <"$comm_file" || continue
comm_lower=${comm_lower,,} comm_lower=${comm_lower,,}
for token_lower in "${MUSIC_PROCESS_NAMES[@]}"; do for token_lower in "${MUSIC_PROCESS_NAMES[@]}"; do
@ -150,8 +153,8 @@ find_music_services() {
fi fi
# Check windows (use optimized is_focus_app_running logic: single xdotool regex call) # 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 command -v xdotool &>/dev/null && [[ ${#MUSIC_SERVICES[@]} -gt 0 ]]; then
if xdotool search --name "$MUSIC_WINDOWS_PATTERN" &> /dev/null 2>&1; then if xdotool search --name "$MUSIC_WINDOWS_PATTERN" &>/dev/null 2>&1; then
MUSIC_FOUND_WINDOW=1 MUSIC_FOUND_WINDOW=1
found_services+=("music service (window)") found_services+=("music service (window)")
fi fi
@ -178,12 +181,12 @@ kill_music_services() {
fi fi
# Close browser tabs for web-based music services via one xdotool search # Close browser tabs for web-based music services via one xdotool search
if [[ $should_check_windows -eq 1 ]] && command -v xdotool &> /dev/null; then if [[ $should_check_windows -eq 1 ]] && command -v xdotool &>/dev/null; then
local windows wid local windows wid
windows=$(xdotool search --name "$window_pattern" 2> /dev/null || true) windows=$(xdotool search --name "$window_pattern" 2>/dev/null || true)
for wid in $windows; do for wid in $windows; do
[[ -n $wid ]] || continue [[ -n $wid ]] || continue
xdotool windowclose "$wid" 2> /dev/null || true xdotool windowclose "$wid" 2>/dev/null || true
killed=true killed=true
done done
fi fi
@ -193,7 +196,7 @@ kill_music_services() {
local comm_file pid comm_lower token_lower local comm_file pid comm_lower token_lower
for comm_file in "$PROC_ROOT"/[0-9]*/comm; do for comm_file in "$PROC_ROOT"/[0-9]*/comm; do
[[ -r $comm_file ]] || continue [[ -r $comm_file ]] || continue
read -r comm_lower < "$comm_file" || continue read -r comm_lower <"$comm_file" || continue
comm_lower=${comm_lower,,} comm_lower=${comm_lower,,}
pid=${comm_file#"$PROC_ROOT"/} pid=${comm_file#"$PROC_ROOT"/}
pid=${pid%%/*} pid=${pid%%/*}
@ -203,7 +206,7 @@ kill_music_services() {
if [[ $PROC_ROOT != "/proc" ]]; then if [[ $PROC_ROOT != "/proc" ]]; then
# Test mode (fake proc tree): mark as killed without signaling host PIDs. # Test mode (fake proc tree): mark as killed without signaling host PIDs.
killed=true killed=true
elif kill -9 "$pid" 2> /dev/null; then elif kill -9 "$pid" 2>/dev/null; then
killed=true killed=true
fi fi
break break
@ -224,8 +227,8 @@ notify_user() {
local message="Music stopped - focus mode active ($focus_app detected)" local message="Music stopped - focus mode active ($focus_app detected)"
# Try to send desktop notification # Try to send desktop notification
if command -v notify-send &> /dev/null; then if command -v notify-send &>/dev/null; then
notify-send -u normal -t 5000 "🎵 Music Parallelism" "$message" 2> /dev/null || true notify-send -u normal -t 5000 "🎵 Music Parallelism" "$message" 2>/dev/null || true
fi fi
log_message "$message" log_message "$message"
@ -246,10 +249,10 @@ instant_monitor_loop() {
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" 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 while true; do
if focus_app=$(is_focus_app_running 2> /dev/null); then if focus_app=$(is_focus_app_running 2>/dev/null); then
current_ts=$(get_timestamp) current_ts=$(get_timestamp)
if (( current_ts >= next_enforcement_ts )); then if ((current_ts >= next_enforcement_ts)); then
if find_music_services > /dev/null 2>&1; then if find_music_services >/dev/null 2>&1; then
if kill_music_services 1; then if kill_music_services 1; then
notify_user "$focus_app" notify_user "$focus_app"
log_message "INSTANT KILL: Music services terminated" log_message "INSTANT KILL: Music services terminated"
@ -310,18 +313,18 @@ show_status() {
local focus_running=false local focus_running=false
# Check windows (OPTIMIZED: single xdotool call with combined regex) # Check windows (OPTIMIZED: single xdotool call with combined regex)
if command -v xdotool &> /dev/null && [[ ${#FOCUS_APPS_WINDOWS[@]} -gt 0 ]]; then if command -v xdotool &>/dev/null && [[ ${#FOCUS_APPS_WINDOWS[@]} -gt 0 ]]; then
local regex local regex
printf -v regex '%s|' "${FOCUS_APPS_WINDOWS[@]}" printf -v regex '%s|' "${FOCUS_APPS_WINDOWS[@]}"
regex="${regex%|}" # strip trailing | regex="${regex%|}" # strip trailing |
if xdotool search --name "$regex" &> /dev/null 2>&1; then if xdotool search --name "$regex" &>/dev/null 2>&1; then
echo " ✓ Focus window detected" echo " ✓ Focus window detected"
focus_running=true focus_running=true
fi fi
fi fi
# Check processes using shared /proc-based helper (fork-free) # Check processes using shared /proc-based helper (fork-free)
if is_focus_app_running > /dev/null 2>&1; then if is_focus_app_running >/dev/null 2>&1; then
echo " ✓ Focus process running" echo " ✓ Focus process running"
focus_running=true focus_running=true
fi fi
@ -333,7 +336,7 @@ show_status() {
echo "" echo ""
echo "Music Services:" echo "Music Services:"
local music_running=false local music_running=false
if music_services=$(find_music_services 2> /dev/null); then if music_services=$(find_music_services 2>/dev/null); then
echo "$music_services" | while read -r svc; do echo "$music_services" | while read -r svc; do
echo " ♪ $svc (RUNNING)" echo " ♪ $svc (RUNNING)"
done done
@ -381,16 +384,16 @@ show_usage() {
# Main # Main
case "${1:-instant}" in case "${1:-instant}" in
monitor | start | run) monitor | start | run)
monitor_loop monitor_loop
;; ;;
instant | fast) instant | fast)
instant_monitor_loop instant_monitor_loop
;; ;;
status) status)
show_status show_status
;; ;;
kill) kill)
log_message "Manual kill requested" log_message "Manual kill requested"
if kill_music_services; then if kill_music_services; then
echo "Music services killed" echo "Music services killed"
@ -398,10 +401,10 @@ case "${1:-instant}" in
echo "No music services found to kill" echo "No music services found to kill"
fi fi
;; ;;
help | -h | --help) help | -h | --help)
show_usage show_usage
;; ;;
*) *)
echo "Unknown command: $1" echo "Unknown command: $1"
show_usage show_usage
exit 1 exit 1

View File

@ -5,18 +5,18 @@ set -euo pipefail
SCRIPT_DIR=${BASH_SOURCE[0]%/*} SCRIPT_DIR=${BASH_SOURCE[0]%/*}
[[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.' [[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.'
# shellcheck source=linux_configuration/i3-configuration/i3blocks/persist_common.sh # shellcheck source=persist_common.sh
source "$SCRIPT_DIR/persist_common.sh" source "$SCRIPT_DIR/persist_common.sh"
check_installed() { check_installed() {
command -v aw-qt > /dev/null 2>&1 || command -v aw-server > /dev/null 2>&1 command -v aw-qt >/dev/null 2>&1 || command -v aw-server >/dev/null 2>&1
} }
check_running() { check_running() {
local proc_file proc_name local proc_file proc_name
for proc_file in /proc/[0-9]*/comm; do for proc_file in /proc/[0-9]*/comm; do
[[ -r $proc_file ]] || continue [[ -r $proc_file ]] || continue
read -r proc_name < "$proc_file" || continue read -r proc_name <"$proc_file" || continue
case $proc_name in case $proc_name in
aw-qt | aw-server) aw-qt | aw-server)
return 0 return 0

View File

@ -5,12 +5,12 @@ set -euo pipefail
SCRIPT_DIR=${BASH_SOURCE[0]%/*} SCRIPT_DIR=${BASH_SOURCE[0]%/*}
[[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.' [[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.'
# shellcheck source=linux_configuration/i3-configuration/i3blocks/persist_common.sh # shellcheck source=persist_common.sh
source "$SCRIPT_DIR/persist_common.sh" source "$SCRIPT_DIR/persist_common.sh"
get_bluetooth_info() { get_bluetooth_info() {
local info local info
info=$(bluetoothctl info 2> /dev/null) || info='' info=$(bluetoothctl info 2>/dev/null) || info=''
printf '%s\n' "$info" printf '%s\n' "$info"
} }
@ -29,7 +29,7 @@ emit() {
device=${line#*Alias: } device=${line#*Alias: }
;; ;;
esac esac
done <<< "$bluetooth_info" done <<<"$bluetooth_info"
state_key="$connected|$device" state_key="$connected|$device"
if ! i3blocks_update_if_changed_key "bluetooth_state" "$state_key"; then if ! i3blocks_update_if_changed_key "bluetooth_state" "$state_key"; then
@ -62,8 +62,8 @@ emit
if is_persist_mode; then if is_persist_mode; then
# React to BlueZ D-Bus signals instead of polling. # React to BlueZ D-Bus signals instead of polling.
if command -v dbus-monitor > /dev/null 2>&1; then if command -v dbus-monitor >/dev/null 2>&1; then
dbus-monitor --system "type='signal',sender='org.bluez'" 2> /dev/null | dbus-monitor --system "type='signal',sender='org.bluez'" 2>/dev/null |
while read -r line; do while read -r line; do
[[ $line == *"PropertiesChanged"* ]] || continue [[ $line == *"PropertiesChanged"* ]] || continue
emit_throttled emit_throttled

View File

@ -5,7 +5,7 @@ set -euo pipefail
SCRIPT_DIR=${BASH_SOURCE[0]%/*} SCRIPT_DIR=${BASH_SOURCE[0]%/*}
[[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.' [[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.'
# shellcheck source=linux_configuration/i3-configuration/i3blocks/persist_common.sh # shellcheck source=persist_common.sh
source "$SCRIPT_DIR/persist_common.sh" source "$SCRIPT_DIR/persist_common.sh"
find_ethernet_interface() { find_ethernet_interface() {
@ -31,7 +31,7 @@ emit() {
return 0 return 0
} }
read -r state < "/sys/class/net/${iface}/operstate" read -r state <"/sys/class/net/${iface}/operstate"
if [[ $state != up ]]; then if [[ $state != up ]]; then
output_line=' down' output_line=' down'
if i3blocks_update_if_changed_key "ethernet_output" "$output_line"; then if i3blocks_update_if_changed_key "ethernet_output" "$output_line"; then
@ -40,7 +40,7 @@ emit() {
return 0 return 0
fi fi
addr_output=$(ip -o -4 addr show dev "$iface" scope global 2> /dev/null) || addr_output='' addr_output=$(ip -o -4 addr show dev "$iface" scope global 2>/dev/null) || addr_output=''
if [[ $addr_output =~ ([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+) ]]; then if [[ $addr_output =~ ([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+) ]]; then
output_line="${BASH_REMATCH[1]}" output_line="${BASH_REMATCH[1]}"
else else
@ -68,7 +68,7 @@ EMIT_MIN_INTERVAL_S=2
emit emit
if is_persist_mode; then if is_persist_mode; then
ip monitor link address route 2> /dev/null | ip monitor link address route 2>/dev/null |
while read -r line; do while read -r line; do
[[ $line == *"eth"* || $line == *"en"* || $line == *"inet "* ]] || continue [[ $line == *"eth"* || $line == *"en"* || $line == *"inet "* ]] || continue
emit_throttled emit_throttled

View File

@ -14,7 +14,7 @@ set -u
SCRIPT_DIR=${BASH_SOURCE[0]%/*} SCRIPT_DIR=${BASH_SOURCE[0]%/*}
[[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.' [[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.'
# shellcheck source=linux_configuration/i3-configuration/i3blocks/persist_common.sh # shellcheck source=persist_common.sh
source "$SCRIPT_DIR/persist_common.sh" source "$SCRIPT_DIR/persist_common.sh"
# Nerd Font glyph: display / desktop icon (U+F108). # Nerd Font glyph: display / desktop icon (U+F108).
@ -43,14 +43,13 @@ emit_if_changed() {
emit "$temp" "$load" emit "$temp" "$load"
} }
# Prefer NVIDIA if present (persist via --loop). # Prefer NVIDIA if present (persist via --loop).
if command -v nvidia-smi > /dev/null 2>&1; then if command -v nvidia-smi >/dev/null 2>&1; then
# One child process for the lifetime of i3blocks; emits CSV every 5s. # One child process for the lifetime of i3blocks; emits CSV every 5s.
nvidia-smi \ nvidia-smi \
--query-gpu=temperature.gpu,utilization.gpu \ --query-gpu=temperature.gpu,utilization.gpu \
--format=csv,noheader,nounits \ --format=csv,noheader,nounits \
--loop=5 2> /dev/null | --loop=5 2>/dev/null |
while IFS=',' read -r temp load; do while IFS=',' read -r temp load; do
# Strip leading/trailing whitespace using parameter expansion. # Strip leading/trailing whitespace using parameter expansion.
temp=${temp## } temp=${temp## }
@ -67,7 +66,7 @@ fi
amdgpu='' amdgpu=''
for d in /sys/class/hwmon/hwmon*/; do for d in /sys/class/hwmon/hwmon*/; do
[[ -r ${d}name ]] || continue [[ -r ${d}name ]] || continue
read -r n < "${d}name" read -r n <"${d}name"
[[ $n == amdgpu ]] && { [[ $n == amdgpu ]] && {
amdgpu=$d amdgpu=$d
break break
@ -76,14 +75,14 @@ done
if [[ -n $amdgpu ]]; then if [[ -n $amdgpu ]]; then
temp='N/A' temp='N/A'
if [[ -r ${amdgpu}temp1_input ]]; then if [[ -r ${amdgpu}temp1_input ]]; then
read -r milli < "${amdgpu}temp1_input" read -r milli <"${amdgpu}temp1_input"
temp=$((milli / 1000)) temp=$((milli / 1000))
fi fi
load='N/A' load='N/A'
# drm card matching the amdgpu hwmon exposes gpu_busy_percent. # drm card matching the amdgpu hwmon exposes gpu_busy_percent.
for card in /sys/class/drm/card*/device/gpu_busy_percent; do for card in /sys/class/drm/card*/device/gpu_busy_percent; do
[[ -r $card ]] && { [[ -r $card ]] && {
read -r load < "$card" read -r load <"$card"
break break
} }
done done

View File

@ -5,10 +5,10 @@ set -euo pipefail
SCRIPT_DIR=${BASH_SOURCE[0]%/*} SCRIPT_DIR=${BASH_SOURCE[0]%/*}
[[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.' [[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.'
# shellcheck source=linux_configuration/i3-configuration/i3blocks/persist_common.sh # shellcheck source=persist_common.sh
source "$SCRIPT_DIR/persist_common.sh" source "$SCRIPT_DIR/persist_common.sh"
if ! command -v warp-cli > /dev/null 2>&1; then if ! command -v warp-cli >/dev/null 2>&1; then
echo " N/A" echo " N/A"
exit 0 exit 0
fi fi
@ -28,7 +28,7 @@ read_status() {
status=${line#Status update: } status=${line#Status update: }
;; ;;
esac esac
done < <(warp-cli status 2> /dev/null) done < <(warp-cli status 2>/dev/null)
printf '%s\n' "$status" printf '%s\n' "$status"
} }

View File

@ -5,7 +5,7 @@ set -euo pipefail
SCRIPT_DIR=${BASH_SOURCE[0]%/*} SCRIPT_DIR=${BASH_SOURCE[0]%/*}
[[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.' [[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.'
# shellcheck source=linux_configuration/i3-configuration/i3blocks/persist_common.sh # shellcheck source=persist_common.sh
source "$SCRIPT_DIR/persist_common.sh" source "$SCRIPT_DIR/persist_common.sh"
find_wifi_interface() { find_wifi_interface() {
@ -17,7 +17,7 @@ find_wifi_interface() {
return 0 return 0
;; ;;
esac esac
done < <(iw dev 2> /dev/null) done < <(iw dev 2>/dev/null)
return 1 return 1
} }
@ -47,7 +47,7 @@ emit() {
ssid='' ssid=''
;; ;;
esac esac
done < <(iw dev "$wifi_interface" link 2> /dev/null) done < <(iw dev "$wifi_interface" link 2>/dev/null)
if [[ -z $ssid ]]; then if [[ -z $ssid ]]; then
output_line=' down' output_line=' down'
@ -60,14 +60,14 @@ emit() {
ip_address='' ip_address=''
while IFS= read -r line; do while IFS= read -r line; do
[[ $line == *' inet '* ]] || continue [[ $line == *' inet '* ]] || continue
read -r -a fields <<< "$line" read -r -a fields <<<"$line"
for ((i = 0; i < ${#fields[@]}; i++)); do for ((i = 0; i < ${#fields[@]}; i++)); do
if [[ ${fields[i]} == inet && $((i + 1)) -lt ${#fields[@]} ]]; then if [[ ${fields[i]} == inet && $((i + 1)) -lt ${#fields[@]} ]]; then
ip_address=${fields[i + 1]%%/*} ip_address=${fields[i + 1]%%/*}
break 2 break 2
fi fi
done done
done < <(ip -o -4 addr show dev "$wifi_interface" scope global 2> /dev/null) done < <(ip -o -4 addr show dev "$wifi_interface" scope global 2>/dev/null)
if [[ -n $ip_address ]]; then if [[ -n $ip_address ]]; then
output_line="$ssid ($signal dBm) $ip_address" output_line="$ssid ($signal dBm) $ip_address"
@ -96,7 +96,7 @@ EMIT_MIN_INTERVAL_S=2
emit emit
if is_persist_mode; then if is_persist_mode; then
ip monitor link address route 2> /dev/null | ip monitor link address route 2>/dev/null |
while read -r line; do while read -r line; do
[[ $line == *"wlan"* || $line == *"wl"* || $line == *"inet "* ]] || continue [[ $line == *"wlan"* || $line == *"wl"* || $line == *"inet "* ]] || continue
emit_throttled emit_throttled

View File

@ -100,7 +100,11 @@ $installed_packages
package:$package package:$package
"*) "*)
log "Blocked app detected: $package - Uninstalling..." log "Blocked app detected: $package - Uninstalling..."
pm uninstall "$package" 2>/dev/null && log "Uninstalled: $package" || log "Failed to uninstall: $package" if pm uninstall "$package" 2>/dev/null; then
log "Uninstalled: $package"
else
log "Failed to uninstall: $package"
fi
;; ;;
esac esac
done <"$BLOCKED_APPS_FILE" done <"$BLOCKED_APPS_FILE"

View File

@ -442,10 +442,21 @@ cmd_status() {
detect_lan detect_lan
echo "=== LAN DNS blocker status ===" echo "=== LAN DNS blocker status ==="
is_service_active dnsmasq && status_line 0 "dnsmasq: active" || status_line 1 "dnsmasq: NOT active" if is_service_active dnsmasq; then
is_service_enabled dnsmasq && status_line 0 "dnsmasq: enabled at boot" || status_line 1 "dnsmasq: NOT enabled" status_line 0 "dnsmasq: active"
[[ -f $DNSMASQ_DROPIN ]] && status_line 0 "Restart=always drop-in present" || status_line 1 "Restart drop-in missing" else
status_line 1 "dnsmasq: NOT active"
fi
if is_service_enabled dnsmasq; then
status_line 0 "dnsmasq: enabled at boot"
else
status_line 1 "dnsmasq: NOT enabled"
fi
if [[ -f $DNSMASQ_DROPIN ]]; then
status_line 0 "Restart=always drop-in present"
else
status_line 1 "Restart drop-in missing"
fi
if [[ -f $FEED ]]; then if [[ -f $FEED ]]; then
status_line 0 "blocklist feed: $(wc -l <"$FEED") lines, updated $(date -r "$FEED" '+%Y-%m-%d %H:%M')" status_line 0 "blocklist feed: $(wc -l <"$FEED") lines, updated $(date -r "$FEED" '+%Y-%m-%d %H:%M')"
else else
@ -472,10 +483,11 @@ cmd_status() {
status_line 0 "firewall: no default-drop ruleset loaded (DNS/DHCP not filtered)" status_line 0 "firewall: no default-drop ruleset loaded (DNS/DHCP not filtered)"
fi fi
systemctl is-active dns-blocklist-refresh.timer &>/dev/null && if systemctl is-active dns-blocklist-refresh.timer &>/dev/null; then
status_line 0 "refresh timer: active (next $(systemctl show -p NextElapseUSecRealtime --value dns-blocklist-refresh.timer 2>/dev/null))" || status_line 0 "refresh timer: active (next $(systemctl show -p NextElapseUSecRealtime --value dns-blocklist-refresh.timer 2>/dev/null))"
else
status_line 1 "refresh timer: NOT active" status_line 1 "refresh timer: NOT active"
fi
if [[ -f $DHCP_CONF ]]; then if [[ -f $DHCP_CONF ]]; then
if ss -ulnp 2>/dev/null | grep -q ':67 '; then if ss -ulnp 2>/dev/null | grep -q ':67 '; then
status_line 0 "DHCP mode: ON (serving leases; PC is the LAN DHCP server)" status_line 0 "DHCP mode: ON (serving leases; PC is the LAN DHCP server)"
@ -492,12 +504,16 @@ cmd_status() {
local blocked passthru local blocked passthru
blocked="$(dig +short +time=2 +tries=1 @"${LAN_IP}" youtube.com 2>/dev/null | head -1)" blocked="$(dig +short +time=2 +tries=1 @"${LAN_IP}" youtube.com 2>/dev/null | head -1)"
passthru="$(dig +short +time=2 +tries=1 @"${LAN_IP}" example.com 2>/dev/null | head -1)" passthru="$(dig +short +time=2 +tries=1 @"${LAN_IP}" example.com 2>/dev/null | head -1)"
[[ $blocked == "0.0.0.0" ]] && if [[ $blocked == "0.0.0.0" ]]; then
status_line 0 "youtube.com -> ${blocked} (BLOCKED, correct)" || status_line 0 "youtube.com -> ${blocked} (BLOCKED, correct)"
else
status_line 1 "youtube.com -> ${blocked:-<no answer>} (expected 0.0.0.0)" status_line 1 "youtube.com -> ${blocked:-<no answer>} (expected 0.0.0.0)"
[[ -n $passthru && $passthru != "0.0.0.0" ]] && fi
status_line 0 "example.com -> ${passthru} (passthrough, correct)" || if [[ -n $passthru && $passthru != "0.0.0.0" ]]; then
status_line 0 "example.com -> ${passthru} (passthrough, correct)"
else
status_line 1 "example.com -> ${passthru:-<no answer>} (expected a real IP)" status_line 1 "example.com -> ${passthru:-<no answer>} (expected a real IP)"
fi
else else
log_warn "install 'dig' (bind) to run the live resolution test." log_warn "install 'dig' (bind) to run the live resolution test."
fi fi

View File

@ -67,9 +67,12 @@ die() {
exit 1 exit 1
} }
# Takes the exit status to leave with, so the error path can print the same
# help and still fail. Hardcoding `exit 0` made the `exit 1` after the
# unknown-command branch unreachable -- the script reported SUCCESS on a typo.
usage() { usage() {
grep -E '^#( |$)' "$0" | sed -E 's/^# ?//' grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
exit 0 exit "${1:-0}"
} }
# --- Phase 1: preflight ----------------------------------------------------- # --- Phase 1: preflight -----------------------------------------------------
@ -394,13 +397,21 @@ check_http() {
status_cmd() { status_cmd() {
print_setup_header "KCD2 dice solver status" print_setup_header "KCD2 dice solver status"
has_cmd node && status_line 0 "node present" || status_line 1 "node missing" if has_cmd node; then
has_cmd pnpm && status_line 0 "pnpm present" || status_line 1 "pnpm missing" status_line 0 "node present"
else
[[ -f "${DICE_SRC}/dist/index.html" ]] && status_line 1 "node missing"
status_line 0 "build present (dist/index.html)" || fi
if has_cmd pnpm; then
status_line 0 "pnpm present"
else
status_line 1 "pnpm missing"
fi
if [[ -f "${DICE_SRC}/dist/index.html" ]]; then
status_line 0 "build present (dist/index.html)"
else
status_line 1 "build missing — run setup" status_line 1 "build missing — run setup"
fi
if docker ps --format '{{.Names}}' | grep -qx "$DICE_CONTAINER"; then if docker ps --format '{{.Names}}' | grep -qx "$DICE_CONTAINER"; then
status_line 0 "${DICE_CONTAINER} container running" status_line 0 "${DICE_CONTAINER} container running"
local pid listeners local pid listeners
@ -418,14 +429,16 @@ status_cmd() {
status_line "$(check_http "http://127.0.0.1:${DICE_PORT}/" "$DICE_DOMAIN")" \ status_line "$(check_http "http://127.0.0.1:${DICE_PORT}/" "$DICE_DOMAIN")" \
"local static server (127.0.0.1:${DICE_PORT}, Host: ${DICE_DOMAIN})" "local static server (127.0.0.1:${DICE_PORT}, Host: ${DICE_DOMAIN})"
[[ -f $DICE_SNIPPET ]] && if [[ -f $DICE_SNIPPET ]]; then
status_line 0 "Caddy snippet present" || status_line 0 "Caddy snippet present"
else
status_line 1 "Caddy snippet missing" status_line 1 "Caddy snippet missing"
fi
getent hosts "$DICE_DOMAIN" >/dev/null && if getent hosts "$DICE_DOMAIN" >/dev/null; then
status_line 0 "${DICE_DOMAIN} resolves" || status_line 0 "${DICE_DOMAIN} resolves"
else
status_line 1 "${DICE_DOMAIN} does not resolve" status_line 1 "${DICE_DOMAIN} does not resolve"
fi
status_line "$(check_http "https://${DICE_DOMAIN}/")" \ status_line "$(check_http "https://${DICE_DOMAIN}/")" \
"external https://${DICE_DOMAIN}/" "external https://${DICE_DOMAIN}/"
@ -438,10 +451,11 @@ status_cmd() {
status_line 1 "post-commit hook missing — re-run setup" status_line 1 "post-commit hook missing — re-run setup"
fi fi
[[ -f $UNIT_FILE ]] && if [[ -f $UNIT_FILE ]]; then
status_line 0 "${UNIT_NAME} installed" || status_line 0 "${UNIT_NAME} installed"
else
status_line 1 "${UNIT_NAME} missing — re-run setup" status_line 1 "${UNIT_NAME} missing — re-run setup"
fi
if systemctl --user is-failed --quiet "$UNIT_NAME"; then if systemctl --user is-failed --quiet "$UNIT_NAME"; then
status_line 1 "last auto-rebuild FAILED — journalctl --user -u ${UNIT_NAME}" status_line 1 "last auto-rebuild FAILED — journalctl --user -u ${UNIT_NAME}"
else else
@ -475,8 +489,7 @@ main() {
;; ;;
*) *)
log_error "Unknown command: $cmd" log_error "Unknown command: $cmd"
usage usage 1
exit 1
;; ;;
esac esac
} }

View File

@ -66,9 +66,13 @@ die() {
exit 1 exit 1
} }
# Takes the exit status to leave with, so the error path can print the same help
# and still fail. It used to hardcode `exit 0`, which made the `exit 1` after the
# unknown-command branch unreachable -- so `setup_personal_website.sh bogus`
# printed an error and then reported SUCCESS to its caller.
usage() { usage() {
grep -E '^#( |$)' "$0" | sed -E 's/^# ?//' grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
exit 0 exit "${1:-0}"
} }
# --- Phase 1: preflight ----------------------------------------------------- # --- Phase 1: preflight -----------------------------------------------------
@ -320,13 +324,21 @@ check_http() {
status_cmd() { status_cmd() {
print_setup_header "Personal website status" print_setup_header "Personal website status"
has_cmd node && status_line 0 "node present" || status_line 1 "node missing" if has_cmd node; then
has_cmd pnpm && status_line 0 "pnpm present" || status_line 1 "pnpm missing" status_line 0 "node present"
else
[[ -f "${WEBSITE_SRC}/dist/index.html" ]] && status_line 1 "node missing"
status_line 0 "build present (dist/index.html)" || fi
if has_cmd pnpm; then
status_line 0 "pnpm present"
else
status_line 1 "pnpm missing"
fi
if [[ -f "${WEBSITE_SRC}/dist/index.html" ]]; then
status_line 0 "build present (dist/index.html)"
else
status_line 1 "build missing — run setup" status_line 1 "build missing — run setup"
fi
if docker ps --format '{{.Names}}' | grep -qx personal-website; then if docker ps --format '{{.Names}}' | grep -qx personal-website; then
status_line 0 "personal-website container running" status_line 0 "personal-website container running"
else else
@ -336,20 +348,26 @@ status_cmd() {
status_line "$(check_http "http://127.0.0.1:${WEBSITE_PORT}/")" \ status_line "$(check_http "http://127.0.0.1:${WEBSITE_PORT}/")" \
"local static server (127.0.0.1:${WEBSITE_PORT})" "local static server (127.0.0.1:${WEBSITE_PORT})"
[[ -f $WEBSITE_SNIPPET ]] && if [[ -f $WEBSITE_SNIPPET ]]; then
status_line 0 "Caddy website snippet present" || status_line 0 "Caddy website snippet present"
else
status_line 1 "Caddy website snippet missing" status_line 1 "Caddy website snippet missing"
grep -qE '^[[:space:]]*import[[:space:]]+/etc/caddy/sites' "$CADDYFILE" 2>/dev/null && fi
status_line 0 "root Caddyfile uses snippet imports" || if grep -qE '^[[:space:]]*import[[:space:]]+/etc/caddy/sites' "$CADDYFILE" 2>/dev/null; then
status_line 0 "root Caddyfile uses snippet imports"
else
status_line 1 "root Caddyfile not migrated to imports" status_line 1 "root Caddyfile not migrated to imports"
fi
getent hosts "$WEBSITE_DOMAIN" >/dev/null && if getent hosts "$WEBSITE_DOMAIN" >/dev/null; then
status_line 0 "${WEBSITE_DOMAIN} resolves" || status_line 0 "${WEBSITE_DOMAIN} resolves"
else
status_line 1 "${WEBSITE_DOMAIN} does not resolve" status_line 1 "${WEBSITE_DOMAIN} does not resolve"
getent hosts "$GITEA_DOMAIN" >/dev/null && fi
status_line 0 "${GITEA_DOMAIN} resolves" || if getent hosts "$GITEA_DOMAIN" >/dev/null; then
status_line 0 "${GITEA_DOMAIN} resolves"
else
status_line 1 "${GITEA_DOMAIN} does not resolve" status_line 1 "${GITEA_DOMAIN} does not resolve"
fi
status_line "$(check_http "https://${WEBSITE_DOMAIN}/")" \ status_line "$(check_http "https://${WEBSITE_DOMAIN}/")" \
"external https://${WEBSITE_DOMAIN}/" "external https://${WEBSITE_DOMAIN}/"
status_line "$(check_http "https://${GITEA_DOMAIN}/")" \ status_line "$(check_http "https://${GITEA_DOMAIN}/")" \
@ -370,8 +388,7 @@ main() {
;; ;;
*) *)
log_error "Unknown command: $cmd" log_error "Unknown command: $cmd"
usage usage 1
exit 1
;; ;;
esac esac
} }

View File

@ -145,19 +145,23 @@ collect_basics() {
} }
check_cpu_governor() { check_cpu_governor() {
local gov_files # An ARRAY, not a newline-joined string: the awk/grep calls below need one
gov_files=$(find /sys/devices/system/cpu -maxdepth 3 -name scaling_governor 2>/dev/null || true) # argument per file. With the old unquoted "$gov_files" an empty result
# also left awk with NO file operands, so it fell through to reading stdin
# and the diagnostic hung instead of reporting 'not found'.
local -a gov_files=()
mapfile -t gov_files < <(find /sys/devices/system/cpu -maxdepth 3 -name scaling_governor 2>/dev/null || true)
if [[ -z $gov_files ]]; then if ((${#gov_files[@]} == 0)); then
add_action "CPU governor files not found (may be unsupported on this platform)." add_action "CPU governor files not found (may be unsupported on this platform)."
return return
fi fi
local summary local summary
summary=$(awk '{count[$1]++} END {for (g in count) printf "%s:%d ", g, count[g]}' $gov_files 2>/dev/null || true) summary=$(awk '{count[$1]++} END {for (g in count) printf "%s:%d ", g, count[g]}' "${gov_files[@]}" 2>/dev/null || true)
echo "CPU governor summary: ${summary:-unknown}" >>"$REPORT_FILE" echo "CPU governor summary: ${summary:-unknown}" >>"$REPORT_FILE"
if grep -q '^powersave$' $gov_files 2>/dev/null; then if grep -q '^powersave$' "${gov_files[@]}" 2>/dev/null; then
add_finding "CPU governor includes 'powersave' on one or more cores; this can make high-end hardware feel slow." add_finding "CPU governor includes 'powersave' on one or more cores; this can make high-end hardware feel slow."
fi fi
} }
@ -261,9 +265,11 @@ check_gpu_state() {
util=$(nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits 2>/dev/null | head -n 1 | xargs || true) util=$(nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits 2>/dev/null | head -n 1 | xargs || true)
power=$(nvidia-smi --query-gpu=power.draw --format=csv,noheader,nounits 2>/dev/null | head -n 1 | xargs || true) power=$(nvidia-smi --query-gpu=power.draw --format=csv,noheader,nounits 2>/dev/null | head -n 1 | xargs || true)
echo "NVIDIA pstate: ${pstate:-unknown}" >>"$REPORT_FILE" {
echo "NVIDIA util: ${util:-unknown}%" >>"$REPORT_FILE" echo "NVIDIA pstate: ${pstate:-unknown}"
echo "NVIDIA power: ${power:-unknown}W" >>"$REPORT_FILE" echo "NVIDIA util: ${util:-unknown}%"
echo "NVIDIA power: ${power:-unknown}W"
} >>"$REPORT_FILE"
if [[ ${pstate:-} == "P0" && ${util:-100} -le 5 ]]; then if [[ ${pstate:-} == "P0" && ${util:-100} -le 5 ]]; then
add_finding "NVIDIA GPU is in P0 high-performance state while mostly idle; this can increase heat and trigger thermal limits." add_finding "NVIDIA GPU is in P0 high-performance state while mostly idle; this can increase heat and trigger thermal limits."

View File

@ -20,8 +20,8 @@ IFS=$'\n\t'
# Source common library for log functions # Source common library for log functions
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# shellcheck source=../../lib/common.sh # shellcheck source=../../../lib/common.sh
source "$SCRIPT_DIR/../../lib/common.sh" source "$SCRIPT_DIR/../../../lib/common.sh"
DO_POLICY=false DO_POLICY=false
SET_DEFAULT=false SET_DEFAULT=false

View File

@ -26,11 +26,11 @@ SCRIPT_NAME="$(basename "$0")"
# Source common library for log functions # Source common library for log functions
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# shellcheck source=../../lib/common.sh # shellcheck source=../../../lib/common.sh
source "$SCRIPT_DIR/../../lib/common.sh" source "$SCRIPT_DIR/../../../lib/common.sh"
usage() { usage() {
cat << EOF cat <<EOF
${SCRIPT_NAME} - Fix Unity Hub sign-in by registering unityhub:// URL handler ${SCRIPT_NAME} - Fix Unity Hub sign-in by registering unityhub:// URL handler
Options: Options:
@ -69,7 +69,7 @@ while [[ $# -gt 0 ]]; do
done done
require_cmd() { require_cmd() {
if ! command -v "$1" > /dev/null 2>&1; then if ! command -v "$1" >/dev/null 2>&1; then
return 1 return 1
fi fi
} }
@ -112,8 +112,8 @@ detect_unityhub() {
local install_type="UNKNOWN" exec_cmd="" local install_type="UNKNOWN" exec_cmd=""
# 1) Flatpak # 1) Flatpak
if command -v flatpak > /dev/null 2>&1; then if command -v flatpak >/dev/null 2>&1; then
if flatpak info com.unity.UnityHub > /dev/null 2>&1; then if flatpak info com.unity.UnityHub >/dev/null 2>&1; then
install_type="FLATPAK" install_type="FLATPAK"
exec_cmd="flatpak run com.unity.UnityHub %U" exec_cmd="flatpak run com.unity.UnityHub %U"
echo "$install_type|$exec_cmd" echo "$install_type|$exec_cmd"
@ -122,7 +122,7 @@ detect_unityhub() {
fi fi
# 2) Native binary in PATH # 2) Native binary in PATH
if command -v unityhub > /dev/null 2>&1; then if command -v unityhub >/dev/null 2>&1; then
local path local path
path="$(command -v unityhub)" path="$(command -v unityhub)"
install_type="NATIVE" install_type="NATIVE"
@ -145,8 +145,8 @@ detect_unityhub() {
local f local f
for f in "$d"/*.desktop; do for f in "$d"/*.desktop; do
[[ -e $f ]] || continue [[ -e $f ]] || continue
if grep -qiE '^(Name|Comment)=.*Unity Hub' "$f" 2> /dev/null || if grep -qiE '^(Name|Comment)=.*Unity Hub' "$f" 2>/dev/null ||
grep -qiE 'Exec=.*unityhub' "$f" 2> /dev/null; then grep -qiE 'Exec=.*unityhub' "$f" 2>/dev/null; then
local exec_line local exec_line
exec_line="$(grep -iE '^Exec=' "$f" | head -n1 | sed 's/^Exec=//')" exec_line="$(grep -iE '^Exec=' "$f" | head -n1 | sed 's/^Exec=//')"
if [[ -n $exec_line ]]; then if [[ -n $exec_line ]]; then
@ -198,7 +198,7 @@ create_handler_desktop() {
local exec_cmd="$1" local exec_cmd="$1"
local dest="$desktop_dir/unityhub-url-handler.desktop" local dest="$desktop_dir/unityhub-url-handler.desktop"
log_info "Writing handler desktop entry: $dest" log_info "Writing handler desktop entry: $dest"
cat > "$dest" << DESK cat >"$dest" <<DESK
[Desktop Entry] [Desktop Entry]
Name=Unity Hub URL Handler Name=Unity Hub URL Handler
Comment=Handle unityhub:// links for Unity Hub sign-in Comment=Handle unityhub:// links for Unity Hub sign-in
@ -218,14 +218,14 @@ DESK
register_mime_handler() { register_mime_handler() {
local desktop_file="$1" local desktop_file="$1"
# Update desktop database if available # Update desktop database if available
if command -v update-desktop-database > /dev/null 2>&1; then if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$desktop_dir" || true update-desktop-database "$desktop_dir" || true
else else
log_warn "update-desktop-database not found (install desktop-file-utils)." log_warn "update-desktop-database not found (install desktop-file-utils)."
fi fi
# Register as default handler for both schemes # Register as default handler for both schemes
if command -v xdg-mime > /dev/null 2>&1; then if command -v xdg-mime >/dev/null 2>&1; then
xdg-mime default "$(basename "$desktop_file")" x-scheme-handler/unityhub || true xdg-mime default "$(basename "$desktop_file")" x-scheme-handler/unityhub || true
xdg-mime default "$(basename "$desktop_file")" x-scheme-handler/unity || true xdg-mime default "$(basename "$desktop_file")" x-scheme-handler/unity || true
else else
@ -238,8 +238,8 @@ register_mime_handler() {
verify_registration() { verify_registration() {
local expected cur1 cur2 local expected cur1 cur2
expected="$(basename "$1")" expected="$(basename "$1")"
cur1="$(xdg-mime query default x-scheme-handler/unityhub 2> /dev/null || true)" cur1="$(xdg-mime query default x-scheme-handler/unityhub 2>/dev/null || true)"
cur2="$(xdg-mime query default x-scheme-handler/unity 2> /dev/null || true)" cur2="$(xdg-mime query default x-scheme-handler/unity 2>/dev/null || true)"
log_info "Current handler (unityhub): ${cur1:-<none>}" log_info "Current handler (unityhub): ${cur1:-<none>}"
log_info "Current handler (unity): ${cur2:-<none>}" log_info "Current handler (unity): ${cur2:-<none>}"
if [[ $cur1 == "$expected" ]]; then if [[ $cur1 == "$expected" ]]; then
@ -252,8 +252,8 @@ verify_registration() {
maybe_test_open() { maybe_test_open() {
if [[ $RUN_TEST == true ]]; then if [[ $RUN_TEST == true ]]; then
log_info "Opening test link: unityhub://v1/editor-signin" log_info "Opening test link: unityhub://v1/editor-signin"
if command -v xdg-open > /dev/null 2>&1; then if command -v xdg-open >/dev/null 2>&1; then
xdg-open 'unityhub://v1/editor-signin' > /dev/null 2>&1 || true xdg-open 'unityhub://v1/editor-signin' >/dev/null 2>&1 || true
log_ok "Test link invoked. Check if Unity Hub launches or focuses." log_ok "Test link invoked. Check if Unity Hub launches or focuses."
else else
log_warn "xdg-open not found; cannot run test automatically." log_warn "xdg-open not found; cannot run test automatically."
@ -286,7 +286,7 @@ main() {
register_mime_handler "$desktop_file" register_mime_handler "$desktop_file"
verify_registration "$desktop_file" verify_registration "$desktop_file"
cat << 'NOTE' cat <<'NOTE'
--- ---
Next steps: Next steps:
- Sign in from Unity Hub. When the browser finishes, ALLOW the prompt to open xdg-open/Unity Hub. - Sign in from Unity Hub. When the browser finishes, ALLOW the prompt to open xdg-open/Unity Hub.

View File

@ -13,8 +13,8 @@ set -euo pipefail
# Source common library for shared functions # Source common library for shared functions
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# shellcheck source=../../lib/common.sh # shellcheck source=../../../lib/common.sh
source "$SCRIPT_DIR/../../lib/common.sh" source "$SCRIPT_DIR/../../../lib/common.sh"
YES=false YES=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@ -62,9 +62,9 @@ try_download_model() {
tmp=$(mktemp) tmp=$(mktemp)
echo "Attempting to download RNNoise model from: $url" >&2 echo "Attempting to download RNNoise model from: $url" >&2
if has_cmd curl; then if has_cmd curl; then
curl -fsSL "$url" -o "$tmp" 2> /dev/null || true curl -fsSL "$url" -o "$tmp" 2>/dev/null || true
else else
wget -qO "$tmp" "$url" 2> /dev/null || true wget -qO "$tmp" "$url" 2>/dev/null || true
fi fi
if [[ -s $tmp ]]; then if [[ -s $tmp ]]; then
mv "$tmp" "$dest" mv "$tmp" "$dest"
@ -145,10 +145,10 @@ done
if has_cmd yay; then if has_cmd yay; then
echo "Attempting to install AUR packages that may include RNNoise models..." >&2 echo "Attempting to install AUR packages that may include RNNoise models..." >&2
set +e set +e
yay -S --noconfirm denoiseit-git 2> /dev/null yay -S --noconfirm denoiseit-git 2>/dev/null
yay -S --noconfirm speech-denoiser-git 2> /dev/null yay -S --noconfirm speech-denoiser-git 2>/dev/null
set -e set -e
mapfile -t found < <(bash -lc 'shopt -s globstar nullglob; for f in /usr/share/**/*.nn /usr/share/**/*.rnnn /usr/local/share/**/*.nn /usr/local/share/**/*.rnnn; do [[ -f "$f" ]] && echo "$f"; done' 2> /dev/null || true) mapfile -t found < <(bash -lc 'shopt -s globstar nullglob; for f in /usr/share/**/*.nn /usr/share/**/*.rnnn /usr/local/share/**/*.nn /usr/local/share/**/*.rnnn; do [[ -f "$f" ]] && echo "$f"; done' 2>/dev/null || true)
if [[ ${#found[@]} -gt 0 ]]; then if [[ ${#found[@]} -gt 0 ]]; then
echo "Found candidate models:" >&2 echo "Found candidate models:" >&2
printf ' %s\n' "${found[@]}" >&2 printf ' %s\n' "${found[@]}" >&2

View File

@ -9,8 +9,8 @@ set -euo pipefail
# Source common library for shared functions # Source common library for shared functions
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# shellcheck source=../../lib/common.sh # shellcheck source=../../../lib/common.sh
source "$SCRIPT_DIR/../../lib/common.sh" source "$SCRIPT_DIR/../../../lib/common.sh"
print_info() { print_info() {
echo "[info] $*" echo "[info] $*"

View File

@ -4,8 +4,8 @@ set -euo pipefail
# Source common library for shared functions # Source common library for shared functions
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# shellcheck source=../../lib/common.sh # shellcheck source=../../../lib/common.sh
source "$SCRIPT_DIR/../../lib/common.sh" source "$SCRIPT_DIR/../../../lib/common.sh"
SCRIPT_NAME="$(basename "$0")" SCRIPT_NAME="$(basename "$0")"
@ -31,7 +31,7 @@ ensure_pacman_packages() {
} }
install_uv() { install_uv() {
if command -v uv > /dev/null 2>&1; then if command -v uv >/dev/null 2>&1; then
info "uv is already installed." info "uv is already installed."
return return
fi fi
@ -42,21 +42,21 @@ install_uv() {
local local_bin="$HOME/.local/bin" local local_bin="$HOME/.local/bin"
if [[ :$PATH: != *":$local_bin:"* ]]; then if [[ :$PATH: != *":$local_bin:"* ]]; then
warn "Adding $local_bin to PATH in ~/.profile and ~/.zshrc. Open a new shell to apply." warn "Adding $local_bin to PATH in ~/.profile and ~/.zshrc. Open a new shell to apply."
printf "\nexport PATH=\"\$HOME/.local/bin:\$PATH\"\n" >> "$HOME/.profile" printf "\nexport PATH=\"\$HOME/.local/bin:\$PATH\"\n" >>"$HOME/.profile"
printf "\nexport PATH=\"\$HOME/.local/bin:\$PATH\"\n" >> "$HOME/.zshrc" printf "\nexport PATH=\"\$HOME/.local/bin:\$PATH\"\n" >>"$HOME/.zshrc"
fi fi
} }
ensure_unity_hub() { ensure_unity_hub() {
if command -v unityhub > /dev/null 2>&1; then if command -v unityhub >/dev/null 2>&1; then
info "Unity Hub already installed." info "Unity Hub already installed."
return return
fi fi
if command -v yay > /dev/null 2>&1; then if command -v yay >/dev/null 2>&1; then
info "Installing Unity Hub from AUR using yay." info "Installing Unity Hub from AUR using yay."
yay -S --needed --noconfirm unityhub yay -S --needed --noconfirm unityhub
elif command -v flatpak > /dev/null 2>&1; then elif command -v flatpak >/dev/null 2>&1; then
warn "Unity Hub not found. Attempting Flatpak installation." warn "Unity Hub not found. Attempting Flatpak installation."
flatpak install -y com.unity.UnityHub || warn "Flatpak installation failed. Install Unity Hub manually via https://unity.com/download" flatpak install -y com.unity.UnityHub || warn "Flatpak installation failed. Install Unity Hub manually via https://unity.com/download"
else else
@ -120,14 +120,14 @@ configure_vscode_mcp() {
if [[ ! -f $mcp_config ]]; then if [[ ! -f $mcp_config ]]; then
info "Creating new VS Code MCP configuration at $mcp_config" info "Creating new VS Code MCP configuration at $mcp_config"
echo '{}' > "$mcp_config" echo '{}' >"$mcp_config"
else else
info "Updating existing VS Code MCP configuration at $mcp_config" info "Updating existing VS Code MCP configuration at $mcp_config"
fi fi
tmp="$(mktemp)" tmp="$(mktemp)"
if ! jq '.' "$mcp_config" > /dev/null 2>&1; then if ! jq '.' "$mcp_config" >/dev/null 2>&1; then
error "Existing $mcp_config is not valid JSON. Please fix it before running this script again." error "Existing $mcp_config is not valid JSON. Please fix it before running this script again."
exit 1 exit 1
fi fi
@ -140,7 +140,7 @@ configure_vscode_mcp() {
args: ["--directory", $path, "run", "server.py"], args: ["--directory", $path, "run", "server.py"],
type: "stdio" type: "stdio"
}' \ }' \
"$mcp_config" > "$tmp" "$mcp_config" >"$tmp"
mv "$tmp" "$mcp_config" mv "$tmp" "$mcp_config"
info "VS Code MCP server configuration updated for UnityMCP." info "VS Code MCP server configuration updated for UnityMCP."
@ -151,13 +151,13 @@ verify_python_version() {
require_command python "python" require_command python "python"
local version local version
version="$( version="$(
python - << 'PY' python - <<'PY'
import sys import sys
print("%d.%d.%d" % sys.version_info[:3]) print("%d.%d.%d" % sys.version_info[:3])
PY PY
)" )"
local major minor local major minor
IFS='.' read -r major minor _ <<< "$version" IFS='.' read -r major minor _ <<<"$version"
if ((major < 3 || (major == 3 && minor < 12))); then if ((major < 3 || (major == 3 && minor < 12))); then
error "Python 3.12+ is required. Detected version $version. Upgrade python before continuing." error "Python 3.12+ is required. Detected version $version. Upgrade python before continuing."
exit 1 exit 1
@ -166,7 +166,7 @@ PY
} }
print_next_steps() { print_next_steps() {
cat << 'EOT' cat <<'EOT'
Next steps: Next steps:
1. Launch Unity Hub and install a Unity Editor version 2021.3 LTS or newer. 1. Launch Unity Hub and install a Unity Editor version 2021.3 LTS or newer.

View File

@ -113,7 +113,7 @@ find_files() {
# Not a git repo - fall back to manual exclusion # Not a git repo - fall back to manual exclusion
local find_args=() local find_args=()
for i in "${!patterns[@]}"; do for i in "${!patterns[@]}"; do
if [ $i -eq 0 ]; then if [ "$i" -eq 0 ]; then
find_args+=(-name "${patterns[$i]}") find_args+=(-name "${patterns[$i]}")
else else
find_args+=(-o -name "${patterns[$i]}") find_args+=(-o -name "${patterns[$i]}")
@ -125,7 +125,7 @@ find_files() {
# No filtering - find all files # No filtering - find all files
local find_args=() local find_args=()
for i in "${!patterns[@]}"; do for i in "${!patterns[@]}"; do
if [ $i -eq 0 ]; then if [ "$i" -eq 0 ]; then
find_args+=(-name "${patterns[$i]}") find_args+=(-name "${patterns[$i]}")
else else
find_args+=(-o -name "${patterns[$i]}") find_args+=(-o -name "${patterns[$i]}")
@ -302,7 +302,7 @@ if [ "$RESPECT_GITIGNORE" = true ] && is_git_repo; then
} | sort -u | wc -l) } | sort -u | wc -l)
echo "Files: $FILE_COUNT (respecting .gitignore)" echo "Files: $FILE_COUNT (respecting .gitignore)"
elif [ "$RESPECT_GITIGNORE" = true ]; then elif [ "$RESPECT_GITIGNORE" = true ]; then
echo "Files: $(find . -type f 2>/dev/null | grep -Ev "/($EXCLUDE_DIRS)/" | wc -l) (excluding common dirs)" echo "Files: $(find . -type f 2>/dev/null | grep -cEv "/($EXCLUDE_DIRS)/") (excluding common dirs)"
else else
echo "Files: $(find . -type f | wc -l)" echo "Files: $(find . -type f | wc -l)"
fi fi
@ -336,7 +336,7 @@ print_header "STEP 4: Fast Keyword Analysis (Code vs Comments)"
fast_count() { fast_count() {
local top_n="${1:-50}" local top_n="${1:-50}"
if command -v counts &>/dev/null; then if command -v counts &>/dev/null; then
counts 2>/dev/null | head -$((top_n + 1)) | tail -$top_n counts 2>/dev/null | head -n "$((top_n + 1))" | tail -n "$top_n"
else else
python3 "$SCRIPT_DIR/fast_count.py" "$top_n" python3 "$SCRIPT_DIR/fast_count.py" "$top_n"
fi fi
@ -387,14 +387,14 @@ HAS_GO=false
HAS_RUST=false HAS_RUST=false
HAS_JAVA=false HAS_JAVA=false
((${LANG_FILES[c]} + ${LANG_FILES[cpp]} + ${LANG_FILES[h]} > 0)) && HAS_C_FAMILY=true ((LANG_FILES[c] + LANG_FILES[cpp] + LANG_FILES[h] > 0)) && HAS_C_FAMILY=true
((${LANG_FILES[python]} > 0)) && HAS_PYTHON=true ((LANG_FILES[python] > 0)) && HAS_PYTHON=true
((${LANG_FILES[javascript]} + ${LANG_FILES[typescript]} > 0)) && HAS_JS_FAMILY=true ((LANG_FILES[javascript] + LANG_FILES[typescript] > 0)) && HAS_JS_FAMILY=true
((${LANG_FILES[shell]} > 0)) && HAS_SHELL=true ((LANG_FILES[shell] > 0)) && HAS_SHELL=true
((${LANG_FILES[ruby]} > 0)) && HAS_RUBY=true ((LANG_FILES[ruby] > 0)) && HAS_RUBY=true
((${LANG_FILES[go]} > 0)) && HAS_GO=true ((LANG_FILES[go] > 0)) && HAS_GO=true
((${LANG_FILES[rust]} > 0)) && HAS_RUST=true ((LANG_FILES[rust] > 0)) && HAS_RUST=true
((${LANG_FILES[java]} > 0)) && HAS_JAVA=true ((LANG_FILES[java] > 0)) && HAS_JAVA=true
#------------------------------------------------------------------------------ #------------------------------------------------------------------------------
# Language-specific keyword definitions # Language-specific keyword definitions
@ -735,7 +735,7 @@ if [ -f "$RESULTS_DIR/tags" ]; then
print_subheader "Symbol Types Distribution" print_subheader "Symbol Types Distribution"
# Fast: extract single-letter kind code after ;" and count # Fast: extract single-letter kind code after ;" and count
grep -aoP ';"\t\K[a-z]' "$RESULTS_DIR/tags" 2>/dev/null | fast_count 20 | while read count kind; do grep -aoP ';"\t\K[a-z]' "$RESULTS_DIR/tags" 2>/dev/null | fast_count 20 | while read -r count kind; do
case $kind in case $kind in
f) echo "$count functions" ;; f) echo "$count functions" ;;
v) echo "$count variables" ;; v) echo "$count variables" ;;

View File

@ -64,7 +64,7 @@ for track in "${!TRACKS[@]}"; do
# Show exercise count # Show exercise count
if [[ -d "$track/exercises/practice" ]]; then if [[ -d "$track/exercises/practice" ]]; then
count=$(ls "$track/exercises/practice" | wc -l) count=$(find "$track/exercises/practice" -mindepth 1 -maxdepth 1 | wc -l)
echo " → $count practice exercises available" echo " → $count practice exercises available"
fi fi
echo "" echo ""
@ -100,7 +100,7 @@ echo ""
echo "Track summary:" echo "Track summary:"
for track in "${!TRACKS[@]}"; do for track in "${!TRACKS[@]}"; do
if [[ -d "$track/exercises/practice" ]]; then if [[ -d "$track/exercises/practice" ]]; then
count=$(ls "$track/exercises/practice" 2> /dev/null | wc -l) count=$(find "$track/exercises/practice" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l)
printf " %-15s %3d exercises\n" "$track" "$count" printf " %-15s %3d exercises\n" "$track" "$count"
fi fi
done | sort done | sort

View File

@ -498,8 +498,10 @@ if [ -d "$PER_LANG_DIR" ]; then
} }
# Process keywords by language # Process keywords by language
echo "## Language Keywords" >>"$DOCS_FILE" {
echo "" >>"$DOCS_FILE" echo "## Language Keywords"
echo ""
} >>"$DOCS_FILE"
for keyword_file in "$PER_LANG_DIR"/keywords_*.txt; do for keyword_file in "$PER_LANG_DIR"/keywords_*.txt; do
[ ! -f "$keyword_file" ] && continue [ ! -f "$keyword_file" ] && continue
@ -523,12 +525,14 @@ if [ -d "$PER_LANG_DIR" ]; then
*) display_lang="$lang" ;; *) display_lang="$lang" ;;
esac esac
echo "### $display_lang Keywords" >>"$DOCS_FILE" {
echo "" >>"$DOCS_FILE" echo "### $display_lang Keywords"
echo "| Keyword | Count | Documentation |" >>"$DOCS_FILE" echo ""
echo "|---------|-------|---------------|" >>"$DOCS_FILE" echo "| Keyword | Count | Documentation |"
echo "|---------|-------|---------------|"
} >>"$DOCS_FILE"
head -$TOP_N "$keyword_file" | while read -r count term; do head -n "$TOP_N" "$keyword_file" | while read -r count term; do
[ -z "$term" ] && continue [ -z "$term" ] && continue
[[ $term =~ ^[#] ]] && continue # Skip comment lines [[ $term =~ ^[#] ]] && continue # Skip comment lines
url=$(get_doc_url "$term" "$doc_lang") url=$(get_doc_url "$term" "$doc_lang")
@ -538,8 +542,10 @@ if [ -d "$PER_LANG_DIR" ]; then
done done
# Process functions by language # Process functions by language
echo "## Function/Method Calls" >>"$DOCS_FILE" {
echo "" >>"$DOCS_FILE" echo "## Function/Method Calls"
echo ""
} >>"$DOCS_FILE"
for func_file in "$PER_LANG_DIR"/functions_*.txt; do for func_file in "$PER_LANG_DIR"/functions_*.txt; do
[ ! -f "$func_file" ] && continue [ ! -f "$func_file" ] && continue
@ -561,12 +567,14 @@ if [ -d "$PER_LANG_DIR" ]; then
*) display_lang="$lang" ;; *) display_lang="$lang" ;;
esac esac
echo "### $display_lang Functions" >>"$DOCS_FILE" {
echo "" >>"$DOCS_FILE" echo "### $display_lang Functions"
echo "| Function | Count | Documentation |" >>"$DOCS_FILE" echo ""
echo "|----------|-------|---------------|" >>"$DOCS_FILE" echo "| Function | Count | Documentation |"
echo "|----------|-------|---------------|"
} >>"$DOCS_FILE"
head -$TOP_N "$func_file" | while read -r count term; do head -n "$TOP_N" "$func_file" | while read -r count term; do
[ -z "$term" ] && continue [ -z "$term" ] && continue
[[ $term =~ ^(if|for|while|switch|catch|elif)$ ]] && continue [[ $term =~ ^(if|for|while|switch|catch|elif)$ ]] && continue
url=$(get_doc_url "$term" "$doc_lang") url=$(get_doc_url "$term" "$doc_lang")
@ -576,8 +584,10 @@ if [ -d "$PER_LANG_DIR" ]; then
done done
# Process imports by language # Process imports by language
echo "## Imports/Includes" >>"$DOCS_FILE" {
echo "" >>"$DOCS_FILE" echo "## Imports/Includes"
echo ""
} >>"$DOCS_FILE"
for import_file in "$PER_LANG_DIR"/imports_*.txt; do for import_file in "$PER_LANG_DIR"/imports_*.txt; do
[ ! -f "$import_file" ] && continue [ ! -f "$import_file" ] && continue
@ -599,10 +609,12 @@ if [ -d "$PER_LANG_DIR" ]; then
*) display_lang="$lang" ;; *) display_lang="$lang" ;;
esac esac
echo "### $display_lang" >>"$DOCS_FILE" {
echo "" >>"$DOCS_FILE" echo "### $display_lang"
echo "| Import | Count | Documentation |" >>"$DOCS_FILE" echo ""
echo "|--------|-------|---------------|" >>"$DOCS_FILE" echo "| Import | Count | Documentation |"
echo "|--------|-------|---------------|"
} >>"$DOCS_FILE"
head -20 "$import_file" | while read -r count import; do head -20 "$import_file" | while read -r count import; do
[ -z "$import" ] && continue [ -z "$import" ] && continue
@ -613,7 +625,7 @@ if [ -d "$PER_LANG_DIR" ]; then
module=$(echo "$import" | sed -E 's/.*[<"]([^">]+)[">].*/\1/' | sed 's|.*/||' | sed 's/\..*$//') module=$(echo "$import" | sed -E 's/.*[<"]([^">]+)[">].*/\1/' | sed 's|.*/||' | sed 's/\..*$//')
url=$(get_doc_url "$module" "$doc_lang") url=$(get_doc_url "$module" "$doc_lang")
fi fi
import_escaped=$(echo "$import" | sed 's/|/\\|/g') import_escaped="${import//|/\\|}"
echo "| \`$import_escaped\` | $count | [docs]($url) |" >>"$DOCS_FILE" echo "| \`$import_escaped\` | $count | [docs]($url) |" >>"$DOCS_FILE"
done done
echo "" >>"$DOCS_FILE" echo "" >>"$DOCS_FILE"
@ -624,12 +636,14 @@ else
echo -e "${YELLOW}No per-language files found, using combined analysis${NC}" echo -e "${YELLOW}No per-language files found, using combined analysis${NC}"
if [ -f "$RESULTS_DIR/grep_keywords.txt" ]; then if [ -f "$RESULTS_DIR/grep_keywords.txt" ]; then
echo "## Language Keywords" >>"$DOCS_FILE" {
echo "" >>"$DOCS_FILE" echo "## Language Keywords"
echo "| Keyword | Count | Documentation |" >>"$DOCS_FILE" echo ""
echo "|---------|-------|---------------|" >>"$DOCS_FILE" echo "| Keyword | Count | Documentation |"
echo "|---------|-------|---------------|"
} >>"$DOCS_FILE"
head -$TOP_N "$RESULTS_DIR/grep_keywords.txt" | while read -r count term; do head -n "$TOP_N" "$RESULTS_DIR/grep_keywords.txt" | while read -r count term; do
[ -z "$term" ] && continue [ -z "$term" ] && continue
url=$(get_doc_url "$term" "$PRIMARY_LANG") url=$(get_doc_url "$term" "$PRIMARY_LANG")
echo "| \`$term\` | $count | [docs]($url) |" >>"$DOCS_FILE" echo "| \`$term\` | $count | [docs]($url) |" >>"$DOCS_FILE"
@ -638,12 +652,14 @@ else
fi fi
if [ -f "$RESULTS_DIR/grep_function_calls.txt" ]; then if [ -f "$RESULTS_DIR/grep_function_calls.txt" ]; then
echo "## Function/Method Calls" >>"$DOCS_FILE" {
echo "" >>"$DOCS_FILE" echo "## Function/Method Calls"
echo "| Function | Count | Documentation |" >>"$DOCS_FILE" echo ""
echo "|----------|-------|---------------|" >>"$DOCS_FILE" echo "| Function | Count | Documentation |"
echo "|----------|-------|---------------|"
} >>"$DOCS_FILE"
head -$TOP_N "$RESULTS_DIR/grep_function_calls.txt" | while read -r count term; do head -n "$TOP_N" "$RESULTS_DIR/grep_function_calls.txt" | while read -r count term; do
[ -z "$term" ] && continue [ -z "$term" ] && continue
[[ $term =~ ^(if|for|while|switch|catch)$ ]] && continue [[ $term =~ ^(if|for|while|switch|catch)$ ]] && continue
url=$(get_doc_url "$term" "$PRIMARY_LANG") url=$(get_doc_url "$term" "$PRIMARY_LANG")
@ -653,25 +669,29 @@ else
fi fi
if [ -f "$RESULTS_DIR/grep_imports.txt" ]; then if [ -f "$RESULTS_DIR/grep_imports.txt" ]; then
echo "## Imports/Includes" >>"$DOCS_FILE" {
echo "" >>"$DOCS_FILE" echo "## Imports/Includes"
echo "| Import | Count | Documentation |" >>"$DOCS_FILE" echo ""
echo "|--------|-------|---------------|" >>"$DOCS_FILE" echo "| Import | Count | Documentation |"
echo "|--------|-------|---------------|"
} >>"$DOCS_FILE"
head -20 "$RESULTS_DIR/grep_imports.txt" | while read -r count import; do head -20 "$RESULTS_DIR/grep_imports.txt" | while read -r count import; do
[ -z "$import" ] && continue [ -z "$import" ] && continue
module=$(echo "$import" | sed -E 's/.*[<"]([^">]+)[">].*/\1/' | sed 's|.*/||' | sed 's/\..*$//') module=$(echo "$import" | sed -E 's/.*[<"]([^">]+)[">].*/\1/' | sed 's|.*/||' | sed 's/\..*$//')
url=$(get_doc_url "$module" "$PRIMARY_LANG") url=$(get_doc_url "$module" "$PRIMARY_LANG")
import_escaped=$(echo "$import" | sed 's/|/\\|/g') import_escaped="${import//|/\\|}"
echo "| \`$import_escaped\` | $count | [docs]($url) |" >>"$DOCS_FILE" echo "| \`$import_escaped\` | $count | [docs]($url) |" >>"$DOCS_FILE"
done done
echo "" >>"$DOCS_FILE" echo "" >>"$DOCS_FILE"
fi fi
fi fi
echo "" >>"$DOCS_FILE" {
echo "---" >>"$DOCS_FILE" echo ""
echo "*Generated by analyze_repo.sh + generate_study_materials.sh*" >>"$DOCS_FILE" echo "---"
echo "*Generated by analyze_repo.sh + generate_study_materials.sh*"
} >>"$DOCS_FILE"
echo -e "${GREEN}Created: $DOCS_FILE${NC}" echo -e "${GREEN}Created: $DOCS_FILE${NC}"
#============================================================================== #==============================================================================
@ -694,7 +714,7 @@ EOF
# Generate cards for top keywords # Generate cards for top keywords
if [ -f "$RESULTS_DIR/grep_keywords.txt" ]; then if [ -f "$RESULTS_DIR/grep_keywords.txt" ]; then
echo "# Keywords" >>"$ANKI_FILE" echo "# Keywords" >>"$ANKI_FILE"
head -$TOP_N "$RESULTS_DIR/grep_keywords.txt" | while read -r count term; do head -n "$TOP_N" "$RESULTS_DIR/grep_keywords.txt" | while read -r count term; do
[ -z "$term" ] && continue [ -z "$term" ] && continue
url=$(get_doc_url "$term" "$PRIMARY_LANG") url=$(get_doc_url "$term" "$PRIMARY_LANG")
@ -730,9 +750,11 @@ fi
# Generate cards for top functions # Generate cards for top functions
if [ -f "$RESULTS_DIR/grep_function_calls.txt" ]; then if [ -f "$RESULTS_DIR/grep_function_calls.txt" ]; then
echo "" >>"$ANKI_FILE" {
echo "# Functions" >>"$ANKI_FILE" echo ""
head -$TOP_N "$RESULTS_DIR/grep_function_calls.txt" | while read -r count term; do echo "# Functions"
} >>"$ANKI_FILE"
head -n "$TOP_N" "$RESULTS_DIR/grep_function_calls.txt" | while read -r count term; do
[ -z "$term" ] && continue [ -z "$term" ] && continue
[[ $term =~ ^(if|for|while|switch|catch)$ ]] && continue [[ $term =~ ^(if|for|while|switch|catch)$ ]] && continue
url=$(get_doc_url "$term" "$PRIMARY_LANG") url=$(get_doc_url "$term" "$PRIMARY_LANG")
@ -780,7 +802,7 @@ generate_keywords_with_docs() {
local keywords_file="$RESULTS_DIR/grep_keywords.txt" local keywords_file="$RESULTS_DIR/grep_keywords.txt"
[ ! -f "$keywords_file" ] && echo "No keywords found" && return [ ! -f "$keywords_file" ] && echo "No keywords found" && return
head -$TOP_N "$keywords_file" | grep -v '^#' | while read -r line; do head -n "$TOP_N" "$keywords_file" | grep -v '^#' | while read -r line; do
local count local count
count=$(echo "$line" | awk '{print $1}') count=$(echo "$line" | awk '{print $1}')
local keyword local keyword
@ -797,7 +819,7 @@ generate_functions_with_docs() {
local functions_file="$RESULTS_DIR/grep_function_calls.txt" local functions_file="$RESULTS_DIR/grep_function_calls.txt"
[ ! -f "$functions_file" ] && echo "No functions found" && return [ ! -f "$functions_file" ] && echo "No functions found" && return
head -$TOP_N "$functions_file" | grep -v '^#' | while read -r line; do head -n "$TOP_N" "$functions_file" | grep -v '^#' | while read -r line; do
local count local count
count=$(echo "$line" | awk '{print $1}') count=$(echo "$line" | awk '{print $1}')
local func local func

View File

@ -34,9 +34,9 @@ echo ""
# Install Exercism CLI # Install Exercism CLI
install_exercism_cli() { install_exercism_cli() {
if command -v exercism &> /dev/null; then if command -v exercism &>/dev/null; then
local version local version
version=$(exercism version 2> /dev/null | head -1) version=$(exercism version 2>/dev/null | head -1)
success "Exercism CLI already installed: $version" success "Exercism CLI already installed: $version"
return 0 return 0
fi fi
@ -44,18 +44,18 @@ install_exercism_cli() {
echo "Installing Exercism CLI..." echo "Installing Exercism CLI..."
# Try package managers first # Try package managers first
if command -v pacman &> /dev/null; then if command -v pacman &>/dev/null; then
# Check AUR # Check AUR
if command -v yay &> /dev/null; then if command -v yay &>/dev/null; then
yay -S --noconfirm exercism-bin yay -S --noconfirm exercism-bin
success "Exercism CLI installed via AUR" success "Exercism CLI installed via AUR"
return 0 return 0
elif command -v paru &> /dev/null; then elif command -v paru &>/dev/null; then
paru -S --noconfirm exercism-bin paru -S --noconfirm exercism-bin
success "Exercism CLI installed via AUR" success "Exercism CLI installed via AUR"
return 0 return 0
fi fi
elif command -v brew &> /dev/null; then elif command -v brew &>/dev/null; then
brew install exercism brew install exercism
success "Exercism CLI installed via Homebrew" success "Exercism CLI installed via Homebrew"
return 0 return 0
@ -110,6 +110,9 @@ install_exercism_cli() {
# Check PATH # Check PATH
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
warn "Add ~/.local/bin to your PATH:" warn "Add ~/.local/bin to your PATH:"
# Printing a line for the USER to paste into their shell rc: $HOME and
# $PATH must appear literally, not be resolved to this machine's values.
# shellcheck disable=SC2016
echo ' export PATH="$HOME/.local/bin:$PATH"' echo ' export PATH="$HOME/.local/bin:$PATH"'
fi fi
} }
@ -146,17 +149,21 @@ install_test_runners() {
echo "" echo ""
# Python - pytest # Python - pytest
if command -v python3 &> /dev/null; then if command -v python3 &>/dev/null; then
if python3 -c "import pytest" 2> /dev/null; then if python3 -c "import pytest" 2>/dev/null; then
success "Python: pytest already installed" success "Python: pytest already installed"
else else
info "Installing pytest for Python exercises..." info "Installing pytest for Python exercises..."
pip3 install --user pytest 2> /dev/null && success "Python: pytest installed" || warn "Python: install pytest manually" if pip3 install --user pytest 2>/dev/null; then
success "Python: pytest installed"
else
warn "Python: install pytest manually"
fi
fi fi
fi fi
# JavaScript/TypeScript - Node.js + npm # JavaScript/TypeScript - Node.js + npm
if command -v node &> /dev/null; then if command -v node &>/dev/null; then
success "JavaScript/TypeScript: Node.js available ($(node --version))" success "JavaScript/TypeScript: Node.js available ($(node --version))"
info " Tests run with: npm test (or jest)" info " Tests run with: npm test (or jest)"
else else
@ -164,7 +171,7 @@ install_test_runners() {
fi fi
# C - gcc + criterion/cmocka # C - gcc + criterion/cmocka
if command -v gcc &> /dev/null; then if command -v gcc &>/dev/null; then
success "C: gcc available" success "C: gcc available"
info " Some C exercises use Unity test framework (included in exercise)" info " Some C exercises use Unity test framework (included in exercise)"
else else
@ -172,7 +179,7 @@ install_test_runners() {
fi fi
# C++ - g++ + Catch2/doctest # C++ - g++ + Catch2/doctest
if command -v g++ &> /dev/null; then if command -v g++ &>/dev/null; then
success "C++: g++ available" success "C++: g++ available"
info " C++ exercises use Catch2 (header-only, included in exercise)" info " C++ exercises use Catch2 (header-only, included in exercise)"
else else
@ -180,12 +187,12 @@ install_test_runners() {
fi fi
# Rust # Rust
if command -v cargo &> /dev/null; then if command -v cargo &>/dev/null; then
success "Rust: cargo available (tests with: cargo test)" success "Rust: cargo available (tests with: cargo test)"
fi fi
# Go # Go
if command -v go &> /dev/null; then if command -v go &>/dev/null; then
success "Go: go available (tests with: go test)" success "Go: go available (tests with: go test)"
fi fi
} }
@ -200,7 +207,7 @@ download_track() {
# Get list of exercises # Get list of exercises
local exercises local exercises
exercises=$(curl -fsSL "https://exercism.org/api/v2/tracks/${track}/exercises" 2> /dev/null | exercises=$(curl -fsSL "https://exercism.org/api/v2/tracks/${track}/exercises" 2>/dev/null |
grep -oP '"slug":"\K[^"]+' | head -n "$count") grep -oP '"slug":"\K[^"]+' | head -n "$count")
if [[ -z $exercises ]]; then if [[ -z $exercises ]]; then
@ -214,7 +221,7 @@ download_track() {
if [[ -d $exercise_dir ]]; then if [[ -d $exercise_dir ]]; then
echo " [exists] $exercise" echo " [exists] $exercise"
else else
if exercism download --track="$track" --exercise="$exercise" 2> /dev/null; then if exercism download --track="$track" --exercise="$exercise" 2>/dev/null; then
echo " [downloaded] $exercise" echo " [downloaded] $exercise"
((downloaded++)) ((downloaded++))
else else
@ -256,7 +263,11 @@ show_usage() {
echo "" echo ""
echo -e "${CYAN}Batch download (requires API token):${NC}" echo -e "${CYAN}Batch download (requires API token):${NC}"
echo " # Download first 20 Python exercises:" echo " # Download first 20 Python exercises:"
# Example commands printed for the user to copy; $(...) and $ex must stay
# literal rather than execute here.
# shellcheck disable=SC2016
echo ' for ex in $(exercism download --track=python 2>&1 | head -20); do' echo ' for ex in $(exercism download --track=python 2>&1 | head -20); do'
# shellcheck disable=SC2016
echo ' exercism download --track=python --exercise=$ex' echo ' exercism download --track=python --exercise=$ex'
echo " done" echo " done"
echo "" echo ""
@ -294,7 +305,7 @@ main() {
if [[ -d $exercise_dir ]]; then if [[ -d $exercise_dir ]]; then
echo " [$track] hello-world already exists" echo " [$track] hello-world already exists"
else else
if exercism download --track="$track" --exercise="hello-world" 2> /dev/null; then if exercism download --track="$track" --exercise="hello-world" 2>/dev/null; then
success "[$track] hello-world downloaded" success "[$track] hello-world downloaded"
else else
warn "[$track] hello-world requires authentication" warn "[$track] hello-world requires authentication"

View File

@ -39,7 +39,7 @@ echo ""
echo "=== 1. Installing Python NLP-based Plagiarism Tools ===" echo "=== 1. Installing Python NLP-based Plagiarism Tools ==="
# Check for Python 3 # Check for Python 3
if ! command -v python3 &> /dev/null; then if ! command -v python3 &>/dev/null; then
error "Python 3 is required but not installed." error "Python 3 is required but not installed."
exit 1 exit 1
fi fi
@ -54,6 +54,9 @@ else
fi fi
# Activate and install packages # Activate and install packages
# Created at runtime by `python -m venv` above, so there is nothing on disk
# for the linter to follow at check time.
# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate" source "$VENV_DIR/bin/activate"
echo "Installing Python packages for text similarity detection..." echo "Installing Python packages for text similarity detection..."
@ -90,11 +93,11 @@ success "NLTK data downloaded"
# Download spaCy English model (small) # Download spaCy English model (small)
echo "Downloading spaCy English model..." echo "Downloading spaCy English model..."
python3 -m spacy download en_core_web_sm 2> /dev/null || warn "spaCy model download may need manual install: python -m spacy download en_core_web_sm" python3 -m spacy download en_core_web_sm 2>/dev/null || warn "spaCy model download may need manual install: python -m spacy download en_core_web_sm"
success "spaCy model installed" success "spaCy model installed"
# Create a simple plagiarism checker script # Create a simple plagiarism checker script
cat > "$INSTALL_DIR/check_plagiarism.py" << 'PYEOF' cat >"$INSTALL_DIR/check_plagiarism.py" <<'PYEOF'
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Simple Text Plagiarism Checker Simple Text Plagiarism Checker
@ -325,7 +328,7 @@ success "Created plagiarism checker script at $INSTALL_DIR/check_plagiarism.py"
# Create convenience wrapper # Create convenience wrapper
mkdir -p "$HOME/.local/bin" mkdir -p "$HOME/.local/bin"
cat > "$HOME/.local/bin/plagcheck" << WRAPEOF cat >"$HOME/.local/bin/plagcheck" <<WRAPEOF
#!/usr/bin/env bash #!/usr/bin/env bash
# Wrapper for plagiarism checker # Wrapper for plagiarism checker
source "$VENV_DIR/bin/activate" source "$VENV_DIR/bin/activate"
@ -345,13 +348,13 @@ echo "=== 2. Installing Sherlock Text Plagiarism Detector ==="
SHERLOCK_DIR="$INSTALL_DIR/sherlock" SHERLOCK_DIR="$INSTALL_DIR/sherlock"
if [ ! -d "$SHERLOCK_DIR" ]; then if [ ! -d "$SHERLOCK_DIR" ]; then
# There are several Sherlock implementations; using a popular Python one # There are several Sherlock implementations; using a popular Python one
if command -v git &> /dev/null; then if command -v git &>/dev/null; then
# Clone a text-based similarity tool # Clone a text-based similarity tool
git clone --depth 1 https://github.com/Zedeldi/sherlock-py.git "$SHERLOCK_DIR" 2> /dev/null || { git clone --depth 1 https://github.com/Zedeldi/sherlock-py.git "$SHERLOCK_DIR" 2>/dev/null || {
warn "Could not clone sherlock-py, trying alternative..." warn "Could not clone sherlock-py, trying alternative..."
# Alternative: Create a simple n-gram based sherlock # Alternative: Create a simple n-gram based sherlock
mkdir -p "$SHERLOCK_DIR" mkdir -p "$SHERLOCK_DIR"
cat > "$SHERLOCK_DIR/sherlock.py" << 'SHERLOCKEOF' cat >"$SHERLOCK_DIR/sherlock.py" <<'SHERLOCKEOF'
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Sherlock - Simple text plagiarism detector using n-gram fingerprinting. Sherlock - Simple text plagiarism detector using n-gram fingerprinting.
@ -459,7 +462,7 @@ fi
echo "" echo ""
echo "=== 3. Checking for Ferret (Java-based plagiarism tool) ===" echo "=== 3. Checking for Ferret (Java-based plagiarism tool) ==="
if command -v java &> /dev/null; then if command -v java &>/dev/null; then
FERRET_DIR="$INSTALL_DIR/ferret" FERRET_DIR="$INSTALL_DIR/ferret"
if [ ! -d "$FERRET_DIR" ]; then if [ ! -d "$FERRET_DIR" ]; then
mkdir -p "$FERRET_DIR" mkdir -p "$FERRET_DIR"
@ -478,7 +481,7 @@ fi
echo "" echo ""
echo "=== 4. WCopyfind Information (Windows tool, needs Wine) ===" echo "=== 4. WCopyfind Information (Windows tool, needs Wine) ==="
if command -v wine &> /dev/null; then if command -v wine &>/dev/null; then
echo "Wine is available. WCopyfind can be run via Wine." echo "Wine is available. WCopyfind can be run via Wine."
echo "Download from: https://plagiarism.bloomfieldmedia.com/software/wcopyfind/" echo "Download from: https://plagiarism.bloomfieldmedia.com/software/wcopyfind/"
echo "Run with: wine /path/to/WCopyfind.exe" echo "Run with: wine /path/to/WCopyfind.exe"
@ -521,6 +524,9 @@ echo " plagcheck thesis_v1.pdf thesis_v2.pdf --detailed"
echo " plagcheck --dir ./student_papers/ --threshold 0.4" echo " plagcheck --dir ./student_papers/ --threshold 0.4"
echo "" echo ""
echo "Note: Ensure ~/.local/bin is in your PATH:" echo "Note: Ensure ~/.local/bin is in your PATH:"
# Printed for the user to paste into their shell rc: $HOME and $PATH must
# stay literal rather than resolve to this machine's values.
# shellcheck disable=SC2016
echo ' export PATH="$HOME/.local/bin:$PATH"' echo ' export PATH="$HOME/.local/bin:$PATH"'
echo "" echo ""
echo "==============================================" echo "=============================================="
@ -528,5 +534,8 @@ echo "=============================================="
# Add to PATH reminder # Add to PATH reminder
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
warn "Add ~/.local/bin to your PATH by adding this to ~/.bashrc or ~/.zshrc:" warn "Add ~/.local/bin to your PATH by adding this to ~/.bashrc or ~/.zshrc:"
# Printed for the user to paste into their shell rc: $HOME and $PATH must
# stay literal rather than resolve to this machine's values.
# shellcheck disable=SC2016
echo ' export PATH="$HOME/.local/bin:$PATH"' echo ' export PATH="$HOME/.local/bin:$PATH"'
fi fi

View File

@ -57,7 +57,7 @@ lookup_python() {
if [ -f "$doc_dir/library/${module_lower}.html" ]; then if [ -f "$doc_dir/library/${module_lower}.html" ]; then
# Find anchor for the specific item in the module # Find anchor for the specific item in the module
local anchor local anchor
anchor=$(grep -oP "id=\"[^\"]*${term}[^\"]*\"" "$doc_dir/library/${module_lower}.html" 2> /dev/null | head -1 | sed 's/id="//;s/"//') anchor=$(grep -oP "id=\"[^\"]*${term}[^\"]*\"" "$doc_dir/library/${module_lower}.html" 2>/dev/null | head -1 | sed 's/id="//;s/"//')
if [ -n "$anchor" ]; then if [ -n "$anchor" ]; then
result="$doc_dir/library/${module_lower}.html#$anchor" result="$doc_dir/library/${module_lower}.html#$anchor"
@ -244,7 +244,7 @@ lookup_python() {
# PRIORITY 3: Built-in functions (library/functions.html) # PRIORITY 3: Built-in functions (library/functions.html)
#-------------------------------------------------------------------------- #--------------------------------------------------------------------------
if [ -z "$result" ] && [ -f "$doc_dir/library/functions.html" ]; then if [ -z "$result" ] && [ -f "$doc_dir/library/functions.html" ]; then
if grep -q "id=\"$term_lower\"" "$doc_dir/library/functions.html" 2> /dev/null; then if grep -q "id=\"$term_lower\"" "$doc_dir/library/functions.html" 2>/dev/null; then
result="$doc_dir/library/functions.html#$term_lower" result="$doc_dir/library/functions.html#$term_lower"
desc="Python built-in function: $term" desc="Python built-in function: $term"
fi fi
@ -300,7 +300,7 @@ lookup_python() {
if [ -z "$result" ]; then if [ -z "$result" ]; then
local found_in local found_in
# Look for exact id match first # Look for exact id match first
found_in=$(grep -l "id=\"$term\"" "$doc_dir/library/"*.html 2> /dev/null | head -1) found_in=$(grep -l "id=\"$term\"" "$doc_dir/library/"*.html 2>/dev/null | head -1)
if [ -n "$found_in" ]; then if [ -n "$found_in" ]; then
result="$found_in#$term" result="$found_in#$term"
local module local module
@ -314,7 +314,7 @@ lookup_python() {
#-------------------------------------------------------------------------- #--------------------------------------------------------------------------
if [ -z "$result" ] && [ -f "$INDEX_DIR/python_index.txt" ]; then if [ -z "$result" ] && [ -f "$INDEX_DIR/python_index.txt" ]; then
local index_match local index_match
index_match=$(grep -i "^$term " "$INDEX_DIR/python_index.txt" 2> /dev/null | head -1) index_match=$(grep -i "^$term " "$INDEX_DIR/python_index.txt" 2>/dev/null | head -1)
if [ -n "$index_match" ]; then if [ -n "$index_match" ]; then
result=$(echo "$index_match" | cut -d' ' -f2-) result=$(echo "$index_match" | cut -d' ' -f2-)
desc="Python: $term (from index)" desc="Python: $term (from index)"
@ -404,7 +404,7 @@ lookup_cpp() {
# Search in files if not found (use -L to follow symlinks) # Search in files if not found (use -L to follow symlinks)
if [ -z "$result" ]; then if [ -z "$result" ]; then
local found local found
found=$(find -L "$doc_dir" -name "*${term}*" -type f 2> /dev/null | head -1) found=$(find -L "$doc_dir" -name "*${term}*" -type f 2>/dev/null | head -1)
if [ -n "$found" ]; then if [ -n "$found" ]; then
result="$found" result="$found"
desc="C/C++: $term" desc="C/C++: $term"
@ -450,7 +450,7 @@ lookup_js() {
local stmt_dir="$mdn_dir/web/javascript/reference/statements/$value" local stmt_dir="$mdn_dir/web/javascript/reference/statements/$value"
if [ -d "$stmt_dir" ] && [ -f "$stmt_dir/index.md" ]; then if [ -d "$stmt_dir" ] && [ -f "$stmt_dir/index.md" ]; then
local title local title
title=$(grep -m1 "^title:" "$stmt_dir/index.md" 2> /dev/null | sed 's/^title:\s*//' | tr -d '"') title=$(grep -m1 "^title:" "$stmt_dir/index.md" 2>/dev/null | sed 's/^title:\s*//' | tr -d '"')
echo "$stmt_dir/index.md|${title:-$term}" echo "$stmt_dir/index.md|${title:-$term}"
return 0 return 0
fi fi
@ -470,7 +470,7 @@ lookup_js() {
local null_dir="$mdn_dir/web/javascript/reference/operators/null" local null_dir="$mdn_dir/web/javascript/reference/operators/null"
if [ -d "$null_dir" ] && [ -f "$null_dir/index.md" ]; then if [ -d "$null_dir" ] && [ -f "$null_dir/index.md" ]; then
local title local title
title=$(grep -m1 "^title:" "$null_dir/index.md" 2> /dev/null | sed 's/^title:\s*//' | tr -d '"') title=$(grep -m1 "^title:" "$null_dir/index.md" 2>/dev/null | sed 's/^title:\s*//' | tr -d '"')
echo "$null_dir/index.md|${title:-null}" echo "$null_dir/index.md|${title:-null}"
return 0 return 0
fi fi
@ -479,7 +479,7 @@ lookup_js() {
local undef_dir="$mdn_dir/web/javascript/reference/global_objects/undefined" local undef_dir="$mdn_dir/web/javascript/reference/global_objects/undefined"
if [ -d "$undef_dir" ] && [ -f "$undef_dir/index.md" ]; then if [ -d "$undef_dir" ] && [ -f "$undef_dir/index.md" ]; then
local title local title
title=$(grep -m1 "^title:" "$undef_dir/index.md" 2> /dev/null | sed 's/^title:\s*//' | tr -d '"') title=$(grep -m1 "^title:" "$undef_dir/index.md" 2>/dev/null | sed 's/^title:\s*//' | tr -d '"')
echo "$undef_dir/index.md|${title:-undefined}" echo "$undef_dir/index.md|${title:-undefined}"
return 0 return 0
fi fi
@ -499,10 +499,10 @@ lookup_js() {
if [ -d "$search_dir" ]; then if [ -d "$search_dir" ]; then
# Look for exact directory match (MDN uses directories with index.md) # Look for exact directory match (MDN uses directories with index.md)
local found_dir local found_dir
found_dir=$(find "$search_dir" -maxdepth 2 -type d -iname "$term" 2> /dev/null | head -1) found_dir=$(find "$search_dir" -maxdepth 2 -type d -iname "$term" 2>/dev/null | head -1)
if [ -n "$found_dir" ] && [ -f "$found_dir/index.md" ]; then if [ -n "$found_dir" ] && [ -f "$found_dir/index.md" ]; then
local title local title
title=$(grep -m1 "^title:" "$found_dir/index.md" 2> /dev/null | sed 's/^title:\s*//' | tr -d '"') title=$(grep -m1 "^title:" "$found_dir/index.md" 2>/dev/null | sed 's/^title:\s*//' | tr -d '"')
echo "$found_dir/index.md|${title:-$term}" echo "$found_dir/index.md|${title:-$term}"
return 0 return 0
fi fi
@ -515,17 +515,17 @@ lookup_js() {
local api_dir="$mdn_dir/web/api/${term_lower}_api" local api_dir="$mdn_dir/web/api/${term_lower}_api"
if [ -d "$api_dir" ] && [ -f "$api_dir/index.md" ]; then if [ -d "$api_dir" ] && [ -f "$api_dir/index.md" ]; then
local title local title
title=$(grep -m1 "^title:" "$api_dir/index.md" 2> /dev/null | sed 's/^title:\s*//' | tr -d '"') title=$(grep -m1 "^title:" "$api_dir/index.md" 2>/dev/null | sed 's/^title:\s*//' | tr -d '"')
echo "$api_dir/index.md|${title:-$term API}" echo "$api_dir/index.md|${title:-$term API}"
return 0 return 0
fi fi
# Then try exact top-level API interface (e.g., Console, Document, Element) # Then try exact top-level API interface (e.g., Console, Document, Element)
local found local found
found=$(find "$mdn_dir/web/api" -maxdepth 1 -type d -iname "$term" 2> /dev/null | head -1) found=$(find "$mdn_dir/web/api" -maxdepth 1 -type d -iname "$term" 2>/dev/null | head -1)
if [ -n "$found" ] && [ -f "$found/index.md" ]; then if [ -n "$found" ] && [ -f "$found/index.md" ]; then
local title local title
title=$(grep -m1 "^title:" "$found/index.md" 2> /dev/null | sed 's/^title:\s*//' | tr -d '"') title=$(grep -m1 "^title:" "$found/index.md" 2>/dev/null | sed 's/^title:\s*//' | tr -d '"')
echo "$found/index.md|${title:-$term}" echo "$found/index.md|${title:-$term}"
return 0 return 0
fi fi
@ -534,16 +534,16 @@ lookup_js() {
local window_method="$mdn_dir/web/api/window/${term_lower}" local window_method="$mdn_dir/web/api/window/${term_lower}"
if [ -d "$window_method" ] && [ -f "$window_method/index.md" ]; then if [ -d "$window_method" ] && [ -f "$window_method/index.md" ]; then
local title local title
title=$(grep -m1 "^title:" "$window_method/index.md" 2> /dev/null | sed 's/^title:\s*//' | tr -d '"') title=$(grep -m1 "^title:" "$window_method/index.md" 2>/dev/null | sed 's/^title:\s*//' | tr -d '"')
echo "$window_method/index.md|${title:-Window.$term()}" echo "$window_method/index.md|${title:-Window.$term()}"
return 0 return 0
fi fi
# Search nested API methods # Search nested API methods
found=$(find "$mdn_dir/web/api" -maxdepth 3 -type d -iname "$term" 2> /dev/null | head -1) found=$(find "$mdn_dir/web/api" -maxdepth 3 -type d -iname "$term" 2>/dev/null | head -1)
if [ -n "$found" ] && [ -f "$found/index.md" ]; then if [ -n "$found" ] && [ -f "$found/index.md" ]; then
local title local title
title=$(grep -m1 "^title:" "$found/index.md" 2> /dev/null | sed 's/^title:\s*//' | tr -d '"') title=$(grep -m1 "^title:" "$found/index.md" 2>/dev/null | sed 's/^title:\s*//' | tr -d '"')
echo "$found/index.md|${title:-$term}" echo "$found/index.md|${title:-$term}"
return 0 return 0
fi fi
@ -552,10 +552,10 @@ lookup_js() {
# Now try partial matches in Global Objects (e.g., Array.from, Object.keys) # Now try partial matches in Global Objects (e.g., Array.from, Object.keys)
if [ -d "$mdn_dir/web/javascript/reference/global_objects" ]; then if [ -d "$mdn_dir/web/javascript/reference/global_objects" ]; then
local found local found
found=$(find "$mdn_dir/web/javascript/reference/global_objects" -maxdepth 2 -type d -iname "*${term}*" 2> /dev/null | head -1) found=$(find "$mdn_dir/web/javascript/reference/global_objects" -maxdepth 2 -type d -iname "*${term}*" 2>/dev/null | head -1)
if [ -n "$found" ] && [ -f "$found/index.md" ]; then if [ -n "$found" ] && [ -f "$found/index.md" ]; then
local title local title
title=$(grep -m1 "^title:" "$found/index.md" 2> /dev/null | sed 's/^title:\s*//' | tr -d '"') title=$(grep -m1 "^title:" "$found/index.md" 2>/dev/null | sed 's/^title:\s*//' | tr -d '"')
echo "$found/index.md|${title:-$term}" echo "$found/index.md|${title:-$term}"
return 0 return 0
fi fi
@ -564,10 +564,10 @@ lookup_js() {
# Glossary as last resort # Glossary as last resort
if [ -d "$mdn_dir/glossary" ]; then if [ -d "$mdn_dir/glossary" ]; then
local found local found
found=$(find "$mdn_dir/glossary" -maxdepth 1 -type d -iname "$term" 2> /dev/null | head -1) found=$(find "$mdn_dir/glossary" -maxdepth 1 -type d -iname "$term" 2>/dev/null | head -1)
if [ -n "$found" ] && [ -f "$found/index.md" ]; then if [ -n "$found" ] && [ -f "$found/index.md" ]; then
local title local title
title=$(grep -m1 "^title:" "$found/index.md" 2> /dev/null | sed 's/^title:\s*//' | tr -d '"') title=$(grep -m1 "^title:" "$found/index.md" 2>/dev/null | sed 's/^title:\s*//' | tr -d '"')
echo "$found/index.md|${title:-$term}" echo "$found/index.md|${title:-$term}"
return 0 return 0
fi fi
@ -584,15 +584,15 @@ lookup_rust() {
local result="" local result=""
local desc="" local desc=""
if command -v rustup &> /dev/null; then if command -v rustup &>/dev/null; then
# Use rustup doc to get path # Use rustup doc to get path
local rust_doc_path local rust_doc_path
rust_doc_path=$(rustup doc --path 2> /dev/null | head -1 | xargs dirname 2> /dev/null) rust_doc_path=$(rustup doc --path 2>/dev/null | head -1 | xargs dirname 2>/dev/null)
# Search in std docs # Search in std docs
if [ -d "$rust_doc_path/std" ]; then if [ -d "$rust_doc_path/std" ]; then
local found local found
found=$(find "$rust_doc_path/std" -name "*${term}*" -type f 2> /dev/null | head -1) found=$(find "$rust_doc_path/std" -name "*${term}*" -type f 2>/dev/null | head -1)
if [ -n "$found" ]; then if [ -n "$found" ]; then
result="$found" result="$found"
desc="Rust: $term" desc="Rust: $term"
@ -613,9 +613,9 @@ lookup_go() {
local result="" local result=""
local desc="" local desc=""
if command -v go &> /dev/null; then if command -v go &>/dev/null; then
# Check if it's a stdlib package # Check if it's a stdlib package
if go doc "$term" &> /dev/null; then if go doc "$term" &>/dev/null; then
result="go doc $term" result="go doc $term"
desc="Go package: $term (use 'go doc $term' to view)" desc="Go package: $term (use 'go doc $term' to view)"
fi fi
@ -637,7 +637,7 @@ lookup_shell() {
# Check bash builtins # Check bash builtins
if [ -f "$doc_dir/bash_builtins.txt" ]; then if [ -f "$doc_dir/bash_builtins.txt" ]; then
if grep -q "=== $term ===" "$doc_dir/bash_builtins.txt" 2> /dev/null; then if grep -q "=== $term ===" "$doc_dir/bash_builtins.txt" 2>/dev/null; then
result="$doc_dir/bash_builtins.txt" result="$doc_dir/bash_builtins.txt"
desc="Bash builtin: $term" desc="Bash builtin: $term"
fi fi
@ -645,7 +645,7 @@ lookup_shell() {
# Check common commands # Check common commands
if [ -z "$result" ] && [ -f "$doc_dir/common_commands.txt" ]; then if [ -z "$result" ] && [ -f "$doc_dir/common_commands.txt" ]; then
if grep -q "^$term" "$doc_dir/common_commands.txt" 2> /dev/null; then if grep -q "^$term" "$doc_dir/common_commands.txt" 2>/dev/null; then
local cmd_desc local cmd_desc
cmd_desc=$(grep "^$term" "$doc_dir/common_commands.txt" | head -1) cmd_desc=$(grep "^$term" "$doc_dir/common_commands.txt" | head -1)
result="$doc_dir/common_commands.txt" result="$doc_dir/common_commands.txt"
@ -656,7 +656,7 @@ lookup_shell() {
# Try man page # Try man page
if [ -z "$result" ]; then if [ -z "$result" ]; then
local man_path local man_path
man_path=$(man -w "$term" 2> /dev/null) man_path=$(man -w "$term" 2>/dev/null)
if [ -n "$man_path" ]; then if [ -n "$man_path" ]; then
result="man $term" result="man $term"
desc="Manual page: $term (use 'man $term' to view)" desc="Manual page: $term (use 'man $term' to view)"
@ -677,7 +677,7 @@ lookup_all() {
# Try each language # Try each language
for lang in python cpp js rust go shell; do for lang in python cpp js rust go shell; do
local result local result
result=$(lookup_$lang "$term" 2> /dev/null) result=$(lookup_$lang "$term" 2>/dev/null)
if [ -n "$result" ]; then if [ -n "$result" ]; then
echo "$lang: $result" echo "$lang: $result"
fi fi
@ -780,17 +780,17 @@ extract_doc_content() {
if [[ $file == *.html ]]; then if [[ $file == *.html ]]; then
# Extract text from HTML, find section about term # Extract text from HTML, find section about term
if command -v html2text &> /dev/null; then if command -v html2text &>/dev/null; then
html2text "$file" 2> /dev/null | grep -A"$max_lines" -i "$term" | head -"$max_lines" html2text "$file" 2>/dev/null | grep -A"$max_lines" -i "$term" | head -"$max_lines"
elif command -v lynx &> /dev/null; then elif command -v lynx &>/dev/null; then
lynx -dump -nolist "$file" 2> /dev/null | grep -A"$max_lines" -i "$term" | head -"$max_lines" lynx -dump -nolist "$file" 2>/dev/null | grep -A"$max_lines" -i "$term" | head -"$max_lines"
else else
# Basic extraction # Basic extraction
sed 's/<[^>]*>//g' "$file" | grep -A"$max_lines" -i "$term" | head -"$max_lines" sed 's/<[^>]*>//g' "$file" | grep -A"$max_lines" -i "$term" | head -"$max_lines"
fi fi
elif [[ $file == *.json ]]; then elif [[ $file == *.json ]]; then
# Pretty print JSON section # Pretty print JSON section
grep -A5 "\"$term\"" "$file" 2> /dev/null grep -A5 "\"$term\"" "$file" 2>/dev/null
else else
# Plain text # Plain text
grep -A"$max_lines" -i "$term" "$file" | head -"$max_lines" grep -A"$max_lines" -i "$term" "$file" | head -"$max_lines"
@ -801,7 +801,7 @@ extract_doc_content() {
# Main # Main
#============================================================================== #==============================================================================
usage() { usage() {
cat << EOF cat <<EOF
Usage: $0 <term> [language] [options] Usage: $0 <term> [language] [options]
Search offline documentation for a term. Search offline documentation for a term.
@ -887,7 +887,7 @@ main() {
if [ "$lang" = "all" ]; then if [ "$lang" = "all" ]; then
lookup_all "$term" lookup_all "$term"
else else
result=$(lookup_$lang "$term" 2> /dev/null) result=$("lookup_$lang" "$term" 2>/dev/null)
if [ -n "$result" ]; then if [ -n "$result" ]; then
local file desc local file desc
file=$(echo "$result" | cut -d'|' -f1) file=$(echo "$result" | cut -d'|' -f1)
@ -903,7 +903,7 @@ main() {
fi fi
if $open_file && [ -f "$file" ]; then if $open_file && [ -f "$file" ]; then
xdg-open "$file" 2> /dev/null & xdg-open "$file" 2>/dev/null &
fi fi
else else
echo -e "${RED}Not found:${NC} $term in $lang documentation" echo -e "${RED}Not found:${NC} $term in $lang documentation"
@ -934,7 +934,7 @@ main() {
echo -e "${CYAN}Looking up:${NC} $line" echo -e "${CYAN}Looking up:${NC} $line"
lookup_import "$line" "$lang" lookup_import "$line" "$lang"
echo "" echo ""
done < "$term" done <"$term"
;; ;;
esac esac
} }

View File

@ -25,7 +25,11 @@ ensure_adb_installed() {
if command -v pacman &>/dev/null; then if command -v pacman &>/dev/null; then
sudo pacman -S --noconfirm android-tools || die "Failed to install android-tools" sudo pacman -S --noconfirm android-tools || die "Failed to install android-tools"
elif command -v apt-get &>/dev/null; then elif command -v apt-get &>/dev/null; then
sudo apt-get update && sudo apt-get install -y adb || die "Failed to install adb" # `apt-get update && apt-get install ... || die` would also die when the
# update succeeded but the install failed to REPORT - and, worse, skipped
# die entirely if update itself failed. Check each step.
sudo apt-get update || die "Failed to update apt package lists"
sudo apt-get install -y adb || die "Failed to install adb"
elif command -v dnf &>/dev/null; then elif command -v dnf &>/dev/null; then
sudo dnf install -y android-tools || die "Failed to install android-tools" sudo dnf install -y android-tools || die "Failed to install android-tools"
else else

View File

@ -42,10 +42,12 @@ fix_cloudflare_key() {
if [[ ${expired:-0} -gt 0 ]]; then if [[ ${expired:-0} -gt 0 ]]; then
log "Refreshing expired Cloudflare WARP GPG key..." log "Refreshing expired Cloudflare WARP GPG key..."
curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg \ if curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg |
| gpg --yes --dearmor -o "$keyring" 2>/dev/null \ gpg --yes --dearmor -o "$keyring" 2>/dev/null; then
&& log "Cloudflare key refreshed." \ log "Cloudflare key refreshed."
|| log "WARNING: Could not refresh Cloudflare key (network issue?). Skipping." else
log "WARNING: Could not refresh Cloudflare key (network issue?). Skipping."
fi
fi fi
} }
@ -66,8 +68,8 @@ fix_wine_legacy_key() {
# Export key to modern location # Export key to modern location
gpg --no-default-keyring --keyring "$legacy_keyring" \ gpg --no-default-keyring --keyring "$legacy_keyring" \
--export "$wine_key_id" \ --export "$wine_key_id" |
| gpg --yes --dearmor -o "$modern_keyring" 2>/dev/null gpg --yes --dearmor -o "$modern_keyring" 2>/dev/null
# Remove from legacy keyring (suppress the deprecation warning) # Remove from legacy keyring (suppress the deprecation warning)
apt-key del "$wine_key_id" >/dev/null 2>&1 || true apt-key del "$wine_key_id" >/dev/null 2>&1 || true

View File

@ -34,12 +34,12 @@ cp "$TARGET_SCRIPT" "$WORKTREE/scripts/periodic_background/utils/android_guardia
printf 'Checking skip-main avoids boot side effects...\n' printf 'Checking skip-main avoids boot side effects...\n'
SKIP_MAIN_GUARDIAN_DIR="$TMP_DIR/skip-main-guardian" SKIP_MAIN_GUARDIAN_DIR="$TMP_DIR/skip-main-guardian"
ANDROID_GUARDIAN_SKIP_MAIN=1 \ ANDROID_GUARDIAN_SKIP_MAIN=1 \
ANDROID_GUARDIAN_DIR="$SKIP_MAIN_GUARDIAN_DIR" \ ANDROID_GUARDIAN_DIR="$SKIP_MAIN_GUARDIAN_DIR" \
ANDROID_GUARDIAN_MODULE_DIR="$TMP_DIR/skip-main-module" \ ANDROID_GUARDIAN_MODULE_DIR="$TMP_DIR/skip-main-module" \
sh "$TARGET_SCRIPT" sh "$TARGET_SCRIPT"
[[ ! -d "$SKIP_MAIN_GUARDIAN_DIR" ]] \ [[ ! -d "$SKIP_MAIN_GUARDIAN_DIR" ]] ||
|| fail 'skip-main should prevent guardian boot setup side effects' fail 'skip-main should prevent guardian boot setup side effects'
printf 'Checking guardian loop cadence constants...\n' printf 'Checking guardian loop cadence constants...\n'
hosts_ticks=$(grep '^HOSTS_CHECK_EVERY_TICKS=' "$WORKTREE/scripts/periodic_background/utils/android_guardian/service.sh" | cut -d= -f2) hosts_ticks=$(grep '^HOSTS_CHECK_EVERY_TICKS=' "$WORKTREE/scripts/periodic_background/utils/android_guardian/service.sh" | cut -d= -f2)
@ -51,19 +51,22 @@ assert_equals '12' "$apps_ticks" 'blocked-app scan should run every 12 ticks'
assert_equals '5' "$sleep_seconds" 'guardian loop should keep the 5 second base sleep' assert_equals '5' "$sleep_seconds" 'guardian loop should keep the 5 second base sleep'
printf 'Checking guardian loop protects module every tick...\n' printf 'Checking guardian loop protects module every tick...\n'
grep -q 'protect_module' "$WORKTREE/scripts/periodic_background/utils/android_guardian/service.sh" \ grep -q 'protect_module' "$WORKTREE/scripts/periodic_background/utils/android_guardian/service.sh" ||
|| fail 'guardian loop must always protect the module each tick' fail 'guardian loop must always protect the module each tick'
printf 'Checking blocked-app scans are cached per pass...\n' printf 'Checking blocked-app scans are cached per pass...\n'
grep -q 'installed_packages=$(pm list packages 2>/dev/null)' "$WORKTREE/scripts/periodic_background/utils/android_guardian/service.sh" \ # Literal search pattern: the $ must reach grep as text, not be expanded
|| fail 'guardian service should cache installed packages once per blocked-app scan' # by this shell.
# shellcheck disable=SC2016
grep -q 'installed_packages=$(pm list packages 2>/dev/null)' "$WORKTREE/scripts/periodic_background/utils/android_guardian/service.sh" ||
fail 'guardian service should cache installed packages once per blocked-app scan'
if grep -q 'pm list packages 2>/dev/null | grep -q' "$WORKTREE/scripts/periodic_background/utils/android_guardian/service.sh"; then if grep -q 'pm list packages 2>/dev/null | grep -q' "$WORKTREE/scripts/periodic_background/utils/android_guardian/service.sh"; then
fail 'guardian service should not re-run pm list packages through grep for every blocked app' fail 'guardian service should not re-run pm list packages through grep for every blocked app'
fi fi
printf 'Checking guardian loop uses skip-main guard for tests...\n' printf 'Checking guardian loop uses skip-main guard for tests...\n'
grep -q 'ANDROID_GUARDIAN_SKIP_MAIN' "$WORKTREE/scripts/periodic_background/utils/android_guardian/service.sh" \ grep -q 'ANDROID_GUARDIAN_SKIP_MAIN' "$WORKTREE/scripts/periodic_background/utils/android_guardian/service.sh" ||
|| fail 'guardian service should support skip-main testing' fail 'guardian service should support skip-main testing'
printf 'android_guardian service regression checks passed.\n' printf 'android_guardian service regression checks passed.\n'

View File

@ -60,7 +60,7 @@ assert_order() {
exit 1 exit 1
fi fi
if (( first_line < second_line )); then if ((first_line < second_line)); then
echo "PASS: $message" echo "PASS: $message"
else else
echo "FAIL: $message" echo "FAIL: $message"
@ -87,6 +87,9 @@ assert_order "$WRAPPER_FILE" 'if ! check_and_handle_db_lock "$@"; then' 'if shou
assert_contains "$WRAPPER_FILE" 'manual_guard_lib_fallback=1' \ assert_contains "$WRAPPER_FILE" 'manual_guard_lib_fallback=1' \
"wrapper tracks whether manual hosts guard fallback was used" "wrapper tracks whether manual hosts guard fallback was used"
# Literal source text being searched for; expanding $SCRIPT_DIR /
# $LOCK_LIB_DEST here would search for this machine's values instead.
# shellcheck disable=SC2016
assert_contains "$INSTALLER_FILE" 'install -m 755 "$SCRIPT_DIR/pacman-hooks/hosts-guard-common.sh" /usr/local/share/hosts-guard/' \ assert_contains "$INSTALLER_FILE" 'install -m 755 "$SCRIPT_DIR/pacman-hooks/hosts-guard-common.sh" /usr/local/share/hosts-guard/' \
"installer deploys shared hosts guard hook helpers" "installer deploys shared hosts guard hook helpers"

View File

@ -202,6 +202,9 @@ fi
# Test: the lock library is covered by the integrity manifest # Test: the lock library is covered by the integrity manifest
echo "[TEST] Verifying installer checksums the lock library..." echo "[TEST] Verifying installer checksums the lock library..."
# Literal source text being searched for; expanding $SCRIPT_DIR /
# $LOCK_LIB_DEST here would search for this machine's values instead.
# shellcheck disable=SC2016
if grep -qF 'sha256sum "$LOCK_LIB_DEST"' "$WRAPPER_DIR/install_pacman_wrapper.sh"; then if grep -qF 'sha256sum "$LOCK_LIB_DEST"' "$WRAPPER_DIR/install_pacman_wrapper.sh"; then
echo "✓ Lock library is included in the integrity manifest" echo "✓ Lock library is included in the integrity manifest"
else else

View File

@ -116,6 +116,10 @@ run_dbus_throttle_case() {
: >"$counter_file" : >"$counter_file"
# The `bash -c` body below is a script for a CHILD shell - $1,
# $TEST_CHECK_INTERVAL, $COUNTER_FILE and ${mock_ts[...]} must reach it
# UNEXPANDED and be evaluated there. Single quotes are load-bearing.
# shellcheck disable=SC2016
calls=$(env -i PATH="$BIN_DIR" SHUTDOWN_TIMER_MONITOR_SKIP_MAIN=1 MOCK_BUSCTL_LINES="day-specific-shutdown.timer\nday-specific-shutdown.timer\nday-specific-shutdown.timer" MOCK_TS_SEQUENCE="$ts_sequence" COUNTER_FILE="$counter_file" TEST_CHECK_INTERVAL="$check_interval" /bin/bash -c ' calls=$(env -i PATH="$BIN_DIR" SHUTDOWN_TIMER_MONITOR_SKIP_MAIN=1 MOCK_BUSCTL_LINES="day-specific-shutdown.timer\nday-specific-shutdown.timer\nday-specific-shutdown.timer" MOCK_TS_SEQUENCE="$ts_sequence" COUNTER_FILE="$counter_file" TEST_CHECK_INTERVAL="$check_interval" /bin/bash -c '
source "$1" source "$1"
CHECK_INTERVAL="$TEST_CHECK_INTERVAL" CHECK_INTERVAL="$TEST_CHECK_INTERVAL"
@ -167,25 +171,32 @@ wait_elapsed=$(env -i PATH="/usr/bin:/bin" SHUTDOWN_TIMER_MONITOR_SKIP_MAIN=1 /b
assert_equals '1' "$wait_elapsed" 'wait_seconds should not return immediately on /dev/null stdin' assert_equals '1' "$wait_elapsed" 'wait_seconds should not return immediately on /dev/null stdin'
printf 'Checking installer template stays in sync with the event-driven monitor...\n' printf 'Checking installer template stays in sync with the event-driven monitor...\n'
grep -Fq 'monitor_with_dbus()' "$SETUP_SCRIPT" \ grep -Fq 'monitor_with_dbus()' "$SETUP_SCRIPT" ||
|| fail 'setup_midnight_shutdown.sh should install the D-Bus monitor helper' fail 'setup_midnight_shutdown.sh should install the D-Bus monitor helper'
grep -Fq 'start_monitoring()' "$SETUP_SCRIPT" \ grep -Fq 'start_monitoring()' "$SETUP_SCRIPT" ||
|| fail 'setup_midnight_shutdown.sh should install the start_monitoring dispatcher' fail 'setup_midnight_shutdown.sh should install the start_monitoring dispatcher'
grep -Fq 'if command -v busctl &>/dev/null; then' "$SETUP_SCRIPT" \ grep -Fq 'if command -v busctl &>/dev/null; then' "$SETUP_SCRIPT" ||
|| fail 'setup_midnight_shutdown.sh should prefer busctl when available' fail 'setup_midnight_shutdown.sh should prefer busctl when available'
grep -Fq 'current_epoch now_ts' "$SETUP_SCRIPT" \ grep -Fq 'current_epoch now_ts' "$SETUP_SCRIPT" ||
|| fail 'setup_midnight_shutdown.sh should use out-var epoch helper in D-Bus throttling path' fail 'setup_midnight_shutdown.sh should use out-var epoch helper in D-Bus throttling path'
# Searching for this literal source text; expanding it would run
# current_epoch and grep for its output instead.
# shellcheck disable=SC2016
if grep -Fq 'now_ts=$(current_epoch)' "$SETUP_SCRIPT"; then if grep -Fq 'now_ts=$(current_epoch)' "$SETUP_SCRIPT"; then
fail 'setup_midnight_shutdown.sh should avoid subshell epoch capture in D-Bus path' fail 'setup_midnight_shutdown.sh should avoid subshell epoch capture in D-Bus path'
fi fi
# Literal source text, as above.
# shellcheck disable=SC2016
if grep -Fq 'now_ts=$(current_epoch)' "$TARGET_SCRIPT"; then if grep -Fq 'now_ts=$(current_epoch)' "$TARGET_SCRIPT"; then
fail 'runtime shutdown monitor should avoid subshell epoch capture in D-Bus path' fail 'runtime shutdown monitor should avoid subshell epoch capture in D-Bus path'
fi fi
grep -Fq 'OnUnitActiveSec=300' "$SETUP_SCRIPT" \ grep -Fq 'OnUnitActiveSec=300' "$SETUP_SCRIPT" ||
|| fail 'setup_midnight_shutdown.sh should run watchdog timer at 300s cadence' fail 'setup_midnight_shutdown.sh should run watchdog timer at 300s cadence'
grep -Fq 'wait_seconds()' "$SETUP_SCRIPT" \ grep -Fq 'wait_seconds()' "$SETUP_SCRIPT" ||
|| fail 'setup_midnight_shutdown.sh should install builtin wait helper in polling fallback' fail 'setup_midnight_shutdown.sh should install builtin wait helper in polling fallback'
grep -Fq 'wait_seconds "$CHECK_INTERVAL"' "$TARGET_SCRIPT" \ # Literal source text, as above.
|| fail 'runtime shutdown monitor polling fallback should use builtin wait helper' # shellcheck disable=SC2016
grep -Fq 'wait_seconds "$CHECK_INTERVAL"' "$TARGET_SCRIPT" ||
fail 'runtime shutdown monitor polling fallback should use builtin wait helper'
printf 'shutdown-timer-monitor.sh regression checks passed.\n' printf 'shutdown-timer-monitor.sh regression checks passed.\n'

View File

@ -23,30 +23,36 @@ logger_template=$(
[[ -n $logger_template ]] || fail 'could not extract nvidia-pmon-logger template from installer' [[ -n $logger_template ]] || fail 'could not extract nvidia-pmon-logger template from installer'
printf 'Checking pmon logger template avoids read -t busy-loop pattern...\n' printf 'Checking pmon logger template avoids read -t busy-loop pattern...\n'
! grep -q 'read -r -t' <<< "$logger_template" \ ! grep -q 'read -r -t' <<<"$logger_template" ||
|| fail 'logger template must not use read -t as sleep surrogate' fail 'logger template must not use read -t as sleep surrogate'
# The template used to poll with `sleep 60` until the day changed. It now # The template used to poll with `sleep 60` until the day changed. It now
# sleeps exactly once, until the next midnight, and blocks on `wait` for the # sleeps exactly once, until the next midnight, and blocks on `wait` for the
# pmon child — strictly fewer wakeups. Assert that property rather than the # pmon child — strictly fewer wakeups. Assert that property rather than the
# old literal, so an accidental return to polling still fails the test. # old literal, so an accidental return to polling still fails the test.
printf 'Checking pmon logger template sleeps until rollover rather than polling...\n' printf 'Checking pmon logger template sleeps until rollover rather than polling...\n'
grep -q 'sleep "\$(seconds_until_next_day)"' <<< "$logger_template" \ # Literal search pattern: the $ must reach grep as text, not be expanded
|| fail 'logger template must sleep until the next day boundary' # by this shell.
# shellcheck disable=SC2016
grep -q 'sleep "\$(seconds_until_next_day)"' <<<"$logger_template" ||
fail 'logger template must sleep until the next day boundary'
grep -q 'seconds_until_next_day()' <<< "$logger_template" \ grep -q 'seconds_until_next_day()' <<<"$logger_template" ||
|| fail 'logger template must define seconds_until_next_day' fail 'logger template must define seconds_until_next_day'
printf 'Checking pmon logger template blocks on wait instead of spinning...\n' printf 'Checking pmon logger template blocks on wait instead of spinning...\n'
grep -qE '^\s*wait "\$pmon_pid"' <<< "$logger_template" \ # Literal search pattern: the $ must reach grep as text, not be expanded
|| fail 'logger template must block on wait for the pmon child' # by this shell.
# shellcheck disable=SC2016
grep -qE '^\s*wait "\$pmon_pid"' <<<"$logger_template" ||
fail 'logger template must block on wait for the pmon child'
printf 'Checking pmon logger template uses fork-free date builtin...\n' printf 'Checking pmon logger template uses fork-free date builtin...\n'
grep -q "printf '%(%Y%m%d)T' -1" <<< "$logger_template" \ grep -q "printf '%(%Y%m%d)T' -1" <<<"$logger_template" ||
|| fail 'logger template must use bash printf time builtin for current day' fail 'logger template must use bash printf time builtin for current day'
printf 'Checking pmon logger template avoids external date command...\n' printf 'Checking pmon logger template avoids external date command...\n'
! grep -q 'date +%Y%m%d' <<< "$logger_template" \ ! grep -q 'date +%Y%m%d' <<<"$logger_template" ||
|| fail 'logger template must not call external date command in hot path' fail 'logger template must not call external date command in hot path'
printf 'Usage monitoring installer efficiency tests passed.\n' printf 'Usage monitoring installer efficiency tests passed.\n'

View File

@ -51,23 +51,28 @@ done
FIX_MODE=false FIX_MODE=false
QUICK_MODE=false QUICK_MODE=false
REPORT_MODE=false REPORT_MODE=false
# Kept as BOTH a string and an array on purpose: run_tool() takes a single
# command STRING (it evals it), so the string form has to stay, while the
# handful of direct invocations below need proper argument splitting that
# only an array gives -- an unquoted "${TARGET_FILES}" there would also glob.
TARGET_FILES="" TARGET_FILES=""
TARGET_FILES_ARR=()
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
--fix|-f) --fix | -f)
FIX_MODE=true FIX_MODE=true
shift shift
;; ;;
--quick|-q) --quick | -q)
QUICK_MODE=true QUICK_MODE=true
shift shift
;; ;;
--report|-r) --report | -r)
REPORT_MODE=true REPORT_MODE=true
shift shift
;; ;;
--help|-h) --help | -h)
echo "Usage: $0 [OPTIONS] [FILES...]" echo "Usage: $0 [OPTIONS] [FILES...]"
echo "" echo ""
echo "Options:" echo "Options:"
@ -85,6 +90,7 @@ while [[ $# -gt 0 ]]; do
;; ;;
*) *)
TARGET_FILES="${TARGET_FILES} $1" TARGET_FILES="${TARGET_FILES} $1"
TARGET_FILES_ARR+=("$1")
shift shift
;; ;;
esac esac
@ -93,6 +99,7 @@ done
# If no target specified, use default paths # If no target specified, use default paths
if [[ -z "${TARGET_FILES}" ]]; then if [[ -z "${TARGET_FILES}" ]]; then
TARGET_FILES="${PYTHON_PATHS[*]}" TARGET_FILES="${PYTHON_PATHS[*]}"
TARGET_FILES_ARR=("${PYTHON_PATHS[@]}")
fi fi
# Create reports directory if needed # Create reports directory if needed
@ -167,7 +174,7 @@ run_tool() {
} }
check_tool() { check_tool() {
if command -v "$1" &> /dev/null; then if command -v "$1" &>/dev/null; then
return 0 return 0
else else
print_warning "$1 not found, skipping..." print_warning "$1 not found, skipping..."
@ -269,14 +276,14 @@ if check_tool radon; then
print_subheader "Running radon (complexity analysis)..." print_subheader "Running radon (complexity analysis)..."
echo "" echo ""
echo -e "${MAGENTA}Cyclomatic Complexity:${NC}" echo -e "${MAGENTA}Cyclomatic Complexity:${NC}"
radon cc -a -s ${TARGET_FILES} || true radon cc -a -s "${TARGET_FILES_ARR[@]}" || true
echo "" echo ""
echo -e "${MAGENTA}Maintainability Index:${NC}" echo -e "${MAGENTA}Maintainability Index:${NC}"
radon mi -s ${TARGET_FILES} || true radon mi -s "${TARGET_FILES_ARR[@]}" || true
if [[ "${REPORT_MODE}" == true ]]; then if [[ "${REPORT_MODE}" == true ]]; then
radon cc -a -s ${TARGET_FILES} > "${PROJECT_ROOT}/lint-reports/radon-cc.txt" 2>&1 || true radon cc -a -s "${TARGET_FILES_ARR[@]}" >"${PROJECT_ROOT}/lint-reports/radon-cc.txt" 2>&1 || true
radon mi -s ${TARGET_FILES} > "${PROJECT_ROOT}/lint-reports/radon-mi.txt" 2>&1 || true radon mi -s "${TARGET_FILES_ARR[@]}" >"${PROJECT_ROOT}/lint-reports/radon-mi.txt" 2>&1 || true
fi fi
fi fi
@ -299,7 +306,7 @@ fi
# ============================================================================== # ==============================================================================
if [[ "${FIX_MODE}" == true ]] && check_tool autoflake; then if [[ "${FIX_MODE}" == true ]] && check_tool autoflake; then
print_subheader "Running autoflake (removing unused imports)..." print_subheader "Running autoflake (removing unused imports)..."
find ${TARGET_FILES} -name "*.py" -type f -exec autoflake --in-place --remove-all-unused-imports --remove-unused-variables {} \; find "${TARGET_FILES_ARR[@]}" -name "*.py" -type f -exec autoflake --in-place --remove-all-unused-imports --remove-unused-variables {} \;
print_success "autoflake completed" print_success "autoflake completed"
fi fi
@ -308,7 +315,7 @@ fi
# ============================================================================== # ==============================================================================
if [[ "${FIX_MODE}" == true ]] && check_tool pyupgrade; then if [[ "${FIX_MODE}" == true ]] && check_tool pyupgrade; then
print_subheader "Running pyupgrade (upgrading syntax to Python 3.10+)..." print_subheader "Running pyupgrade (upgrading syntax to Python 3.10+)..."
find ${TARGET_FILES} -name "*.py" -type f -exec pyupgrade --py310-plus {} \; find "${TARGET_FILES_ARR[@]}" -name "*.py" -type f -exec pyupgrade --py310-plus {} \;
print_success "pyupgrade completed" print_success "pyupgrade completed"
fi fi

View File

@ -39,7 +39,7 @@ profile_polling_scripts() {
# Use perf/strace to capture system calls # Use perf/strace to capture system calls
( (
timeout "$duration" strace -f -e trace=clone,execve -c -p $$ 2>&1 || true timeout "$duration" strace -f -e trace=clone,execve -c -p $$ 2>&1 || true
) > "$trace_file" 2>&1 ) >"$trace_file" 2>&1
echo "Trace completed. Analyzing results:" >&2 echo "Trace completed. Analyzing results:" >&2
echo "" >&2 echo "" >&2
@ -66,8 +66,8 @@ diagnose_polling_scripts() {
# Pattern 1: while true with sleep (no event-driven check) # Pattern 1: while true with sleep (no event-driven check)
echo "1. Polling loops (while true + sleep):" >&2 echo "1. Polling loops (while true + sleep):" >&2
set +e set +e
grep -r "while true\|while :" --include="*.sh" "$SCRIPT_DIR" 2>/dev/null \ grep -r "while true\|while :" --include="*.sh" "$SCRIPT_DIR" 2>/dev/null |
| grep -v "Binary" | grep -v ".git" | head -5 grep -v "Binary" | grep -v ".git" | head -5
set -e set -e
issues_found=$((issues_found + 1)) issues_found=$((issues_found + 1))
echo "" >&2 echo "" >&2
@ -75,8 +75,11 @@ diagnose_polling_scripts() {
# Pattern 2: $(date +...) calls in loops (fork-heavy) # Pattern 2: $(date +...) calls in loops (fork-heavy)
echo "2. Excessive date calls (each forks a process):" >&2 echo "2. Excessive date calls (each forks a process):" >&2
set +e set +e
grep -r '\$(date' --include="*.sh" "$SCRIPT_DIR" 2>/dev/null \ # Literal search pattern: the $ must reach grep as text, not be expanded
| grep -v "Binary" | grep -v ".git" | head -5 # by this shell.
# shellcheck disable=SC2016
grep -r '\$(date' --include="*.sh" "$SCRIPT_DIR" 2>/dev/null |
grep -v "Binary" | grep -v ".git" | head -5
set -e set -e
issues_found=$((issues_found + 1)) issues_found=$((issues_found + 1))
echo "" >&2 echo "" >&2
@ -84,8 +87,8 @@ diagnose_polling_scripts() {
# Pattern 3: pgrep/xdotool in loops # Pattern 3: pgrep/xdotool in loops
echo "3. Process inspection in loops (pgrep, xdotool):" >&2 echo "3. Process inspection in loops (pgrep, xdotool):" >&2
set +e set +e
grep -r "while.*pgrep\|while.*xdotool\|pgrep.*while" --include="*.sh" "$SCRIPT_DIR" 2>/dev/null \ grep -r "while.*pgrep\|while.*xdotool\|pgrep.*while" --include="*.sh" "$SCRIPT_DIR" 2>/dev/null |
| grep -v "Binary" | grep -v ".git" | head -5 grep -v "Binary" | grep -v ".git" | head -5
set -e set -e
issues_found=$((issues_found + 1)) issues_found=$((issues_found + 1))
echo "" >&2 echo "" >&2
@ -95,9 +98,9 @@ diagnose_polling_scripts() {
set +e set +e
while_true_file_list="$(mktemp)" while_true_file_list="$(mktemp)"
heavy_pipe_matches="$(mktemp)" heavy_pipe_matches="$(mktemp)"
grep -r "while true" --include="*.sh" "$SCRIPT_DIR" > "$while_true_file_list" 2>/dev/null grep -r "while true" --include="*.sh" "$SCRIPT_DIR" >"$while_true_file_list" 2>/dev/null
if [ -s "$while_true_file_list" ]; then if [ -s "$while_true_file_list" ]; then
xargs grep -l -e " | awk" -e " | grep" -e " | tr" < "$while_true_file_list" > "$heavy_pipe_matches" 2>/dev/null xargs grep -l -e " | awk" -e " | grep" -e " | tr" <"$while_true_file_list" >"$heavy_pipe_matches" 2>/dev/null
head -5 "$heavy_pipe_matches" head -5 "$heavy_pipe_matches"
fi fi
rm -f "$while_true_file_list" "$heavy_pipe_matches" rm -f "$while_true_file_list" "$heavy_pipe_matches"
@ -108,8 +111,8 @@ diagnose_polling_scripts() {
# Pattern 5: sleep with very short intervals # Pattern 5: sleep with very short intervals
echo "5. Aggressive polling (sleep < 1s):" >&2 echo "5. Aggressive polling (sleep < 1s):" >&2
set +e set +e
grep -rE "sleep 0\.[0-9]|sleep 0[^0-9]" --include="*.sh" "$SCRIPT_DIR" 2>/dev/null \ grep -rE "sleep 0\.[0-9]|sleep 0[^0-9]" --include="*.sh" "$SCRIPT_DIR" 2>/dev/null |
| grep -v "Binary" | grep -v ".git" | head -5 grep -v "Binary" | grep -v ".git" | head -5
set -e set -e
issues_found=$((issues_found + 1)) issues_found=$((issues_found + 1))
echo "" >&2 echo "" >&2
@ -125,19 +128,19 @@ diagnose_polling_scripts() {
# Handle special modes # Handle special modes
case "${1:-}" in case "${1:-}" in
--profile) --profile)
profile_polling_scripts "${2:-60}" profile_polling_scripts "${2:-60}"
exit 0 exit 0
;; ;;
--diagnose) --diagnose)
diagnose_polling_scripts diagnose_polling_scripts
exit 0 exit 0
;; ;;
--init-artifacts) --init-artifacts)
shift shift
exec "$ARTIFACT_INIT_SCRIPT" "$@" exec "$ARTIFACT_INIT_SCRIPT" "$@"
;; ;;
--help) --help)
grep '^# Usage:' "$0" | sed 's/^# //' | head -1 grep '^# Usage:' "$0" | sed 's/^# //' | head -1
grep '^# ' "$0" | sed 's/^# / /' grep '^# ' "$0" | sed 's/^# / /'
exit 0 exit 0

View File

@ -11,7 +11,9 @@ errors=0
files_checked=0 files_checked=0
# Patterns to detect (these are red flags for fork storms) # Patterns to detect (these are red flags for fork storms)
# shellcheck disable=SC2034 # These are regex patterns held literally -- the $( and ` inside them are
# part of what we search FOR, so they must not be expanded here.
# shellcheck disable=SC2034,SC2016
declare -A patterns=( declare -A patterns=(
['while_true_with_sleep']='while\s+(true|:).*sleep' ['while_true_with_sleep']='while\s+(true|:).*sleep'
['date_in_loop']='while.*date|for.*date' ['date_in_loop']='while.*date|for.*date'
@ -81,7 +83,7 @@ check_file() {
echo " $file:" echo " $file:"
while IFS= read -r finding; do while IFS= read -r finding; do
echo " Line $finding" echo " Line $finding"
done <<< "$findings" done <<<"$findings"
errors=$((errors + 1)) errors=$((errors + 1))
return 1 return 1
fi fi

View File

@ -7,12 +7,12 @@
# #
# Safe by default: nothing is deleted without --clean AND user confirmation. # Safe by default: nothing is deleted without --clean AND user confirmation.
RED='\033[0;31m' RED=$'\033[0;31m'
GREEN='\033[0;32m' GREEN=$'\033[0;32m'
YELLOW='\033[1;33m' YELLOW=$'\033[1;33m'
CYAN='\033[0;36m' CYAN=$'\033[0;36m'
BOLD='\033[1m' BOLD=$'\033[1m'
RESET='\033[0m' RESET=$'\033[0m'
CLEAN=false CLEAN=false
if [[ "${1:-}" == "--clean" ]]; then if [[ "${1:-}" == "--clean" ]]; then
@ -25,9 +25,9 @@ TOTAL_RECLAIMABLE=0
human_readable() { human_readable() {
local kb=$1 local kb=$1
if (( kb >= 1048576 )); then if ((kb >= 1048576)); then
printf "%.1f GB" "$(echo "scale=1; $kb / 1048576" | bc)" printf "%.1f GB" "$(echo "scale=1; $kb / 1048576" | bc)"
elif (( kb >= 1024 )); then elif ((kb >= 1024)); then
printf "%.1f MB" "$(echo "scale=1; $kb / 1024" | bc)" printf "%.1f MB" "$(echo "scale=1; $kb / 1024" | bc)"
else else
printf "%d KB" "$kb" printf "%d KB" "$kb"
@ -45,7 +45,7 @@ dir_size_kb() {
report() { report() {
local label="$1" size_kb="$2" detail="${3:-}" local label="$1" size_kb="$2" detail="${3:-}"
if (( size_kb > 0 )); then if ((size_kb > 0)); then
local hr local hr
hr=$(human_readable "$size_kb") hr=$(human_readable "$size_kb")
printf "${YELLOW}%-40s${RESET} %10s" "$label" "$hr" printf "${YELLOW}%-40s${RESET} %10s" "$label" "$hr"
@ -53,7 +53,7 @@ report() {
printf " ${CYAN}(%s)${RESET}" "$detail" printf " ${CYAN}(%s)${RESET}" "$detail"
fi fi
printf "\n" printf "\n"
TOTAL_RECLAIMABLE=$(( TOTAL_RECLAIMABLE + size_kb )) TOTAL_RECLAIMABLE=$((TOTAL_RECLAIMABLE + size_kb))
fi fi
} }
@ -76,14 +76,14 @@ try_clean() {
shift shift
if confirm "$prompt"; then if confirm "$prompt"; then
"$@" "$@"
printf "${GREEN} ✓ Done${RESET}\n" printf "%s ✓ Done%s\n" "${GREEN}" "${RESET}"
fi fi
return 0 return 0
} }
# ───────────────────── checks ───────────────────── # ───────────────────── checks ─────────────────────
printf "\n${BOLD}=== Disk Cleanup Analysis ===${RESET}\n\n" printf "\n%s=== Disk Cleanup Analysis ===%s\n\n" "${BOLD}" "${RESET}"
printf "${BOLD}%-40s %10s${RESET}\n" "Category" "Reclaimable" printf "${BOLD}%-40s %10s${RESET}\n" "Category" "Reclaimable"
printf '%s\n' "$(printf '%.0s─' {1..60})" printf '%s\n' "$(printf '%.0s─' {1..60})"
@ -91,16 +91,16 @@ printf '%s\n' "$(printf '%.0s─' {1..60})"
trash_dir="$HOME/.local/share/Trash" trash_dir="$HOME/.local/share/Trash"
size=$(dir_size_kb "$trash_dir") size=$(dir_size_kb "$trash_dir")
report "Trash" "$size" "empty trash" report "Trash" "$size" "empty trash"
if (( size > 0 )); then if ((size > 0)); then
try_clean "Empty trash?" \ try_clean "Empty trash?" \
rm -rf "${trash_dir}/files" "${trash_dir}/info" "${trash_dir}/expunged" rm -rf "${trash_dir}/files" "${trash_dir}/info" "${trash_dir}/expunged"
fi fi
# 2. Pacman cache # 2. Pacman cache
pacman_cache_size=$(dir_size_kb "/var/cache/pacman/pkg") pacman_cache_size=$(dir_size_kb "/var/cache/pacman/pkg")
if (( pacman_cache_size > 0 )); then if ((pacman_cache_size > 0)); then
if command -v paccache &>/dev/null; then if command -v paccache &>/dev/null; then
pkg_count=$(ls /var/cache/pacman/pkg/ 2>/dev/null | wc -l) pkg_count=$(find /var/cache/pacman/pkg/ -maxdepth 1 -type f -name '*.pkg.tar*' 2>/dev/null | wc -l)
report "Pacman cache ($pkg_count pkgs)" "$pacman_cache_size" \ report "Pacman cache ($pkg_count pkgs)" "$pacman_cache_size" \
"sudo paccache -rk2" "sudo paccache -rk2"
try_clean "Clean pacman cache (keep last 2 versions)?" \ try_clean "Clean pacman cache (keep last 2 versions)?" \
@ -115,7 +115,7 @@ fi
yay_cache="$HOME/.cache/yay" yay_cache="$HOME/.cache/yay"
size=$(dir_size_kb "$yay_cache") size=$(dir_size_kb "$yay_cache")
report "Yay AUR build cache" "$size" "rm -rf ~/.cache/yay" report "Yay AUR build cache" "$size" "rm -rf ~/.cache/yay"
if (( size > 0 )); then if ((size > 0)); then
try_clean "Clear yay build cache?" rm -rf "${yay_cache:?}" try_clean "Clear yay build cache?" rm -rf "${yay_cache:?}"
fi fi
@ -123,7 +123,7 @@ fi
pip_cache="$HOME/.cache/pip" pip_cache="$HOME/.cache/pip"
size=$(dir_size_kb "$pip_cache") size=$(dir_size_kb "$pip_cache")
report "Pip cache" "$size" "pip cache purge" report "Pip cache" "$size" "pip cache purge"
if (( size > 0 )); then if ((size > 0)); then
try_clean "Clear pip cache?" rm -rf "${pip_cache:?}" try_clean "Clear pip cache?" rm -rf "${pip_cache:?}"
fi fi
@ -131,7 +131,7 @@ fi
uv_cache="$HOME/.cache/uv" uv_cache="$HOME/.cache/uv"
size=$(dir_size_kb "$uv_cache") size=$(dir_size_kb "$uv_cache")
report "uv cache" "$size" "uv cache clean" report "uv cache" "$size" "uv cache clean"
if (( size > 0 )); then if ((size > 0)); then
try_clean "Clear uv cache?" rm -rf "${uv_cache:?}" try_clean "Clear uv cache?" rm -rf "${uv_cache:?}"
fi fi
@ -149,7 +149,7 @@ report "Suno AI cache" "$size" "manual review recommended"
go_cache="$HOME/.cache/go-build" go_cache="$HOME/.cache/go-build"
size=$(dir_size_kb "$go_cache") size=$(dir_size_kb "$go_cache")
report "Go build cache" "$size" "go clean -cache" report "Go build cache" "$size" "go clean -cache"
if (( size > 0 )); then if ((size > 0)); then
try_clean "Clear Go build cache?" rm -rf "${go_cache:?}" try_clean "Clear Go build cache?" rm -rf "${go_cache:?}"
fi fi
@ -157,7 +157,7 @@ fi
yarn_cache="$HOME/.cache/yarn" yarn_cache="$HOME/.cache/yarn"
size=$(dir_size_kb "$yarn_cache") size=$(dir_size_kb "$yarn_cache")
report "Yarn cache" "$size" "yarn cache clean" report "Yarn cache" "$size" "yarn cache clean"
if (( size > 0 )); then if ((size > 0)); then
try_clean "Clear yarn cache?" rm -rf "${yarn_cache:?}" try_clean "Clear yarn cache?" rm -rf "${yarn_cache:?}"
fi fi
@ -165,7 +165,7 @@ fi
npm_cache="$HOME/.npm/_cacache" npm_cache="$HOME/.npm/_cacache"
size=$(dir_size_kb "$npm_cache") size=$(dir_size_kb "$npm_cache")
report "npm cache" "$size" "npm cache clean --force" report "npm cache" "$size" "npm cache clean --force"
if (( size > 0 )); then if ((size > 0)); then
try_clean "Clear npm cache?" rm -rf "${npm_cache:?}" try_clean "Clear npm cache?" rm -rf "${npm_cache:?}"
fi fi
@ -173,7 +173,7 @@ fi
electron_cache="$HOME/.cache/electron" electron_cache="$HOME/.cache/electron"
size=$(dir_size_kb "$electron_cache") size=$(dir_size_kb "$electron_cache")
report "Electron cache" "$size" report "Electron cache" "$size"
if (( size > 0 )); then if ((size > 0)); then
try_clean "Clear Electron cache?" rm -rf "${electron_cache:?}" try_clean "Clear Electron cache?" rm -rf "${electron_cache:?}"
fi fi
@ -181,14 +181,14 @@ fi
thumb_cache="$HOME/.cache/thumbnails" thumb_cache="$HOME/.cache/thumbnails"
size=$(dir_size_kb "$thumb_cache") size=$(dir_size_kb "$thumb_cache")
report "Thumbnails cache" "$size" report "Thumbnails cache" "$size"
if (( size > 0 )); then if ((size > 0)); then
try_clean "Clear thumbnails?" rm -rf "${thumb_cache:?}" try_clean "Clear thumbnails?" rm -rf "${thumb_cache:?}"
fi fi
# 13. vscode-cpptools cache # 13. vscode-cpptools cache
cpptools_cache="$HOME/.cache/vscode-cpptools" cpptools_cache="$HOME/.cache/vscode-cpptools"
size=$(dir_size_kb "$cpptools_cache") size=$(dir_size_kb "$cpptools_cache")
if (( size > 102400 )); then if ((size > 102400)); then
report "VS Code cpptools cache" "$size" report "VS Code cpptools cache" "$size"
try_clean "Clear cpptools cache?" rm -rf "${cpptools_cache:?}" try_clean "Clear cpptools cache?" rm -rf "${cpptools_cache:?}"
fi fi
@ -197,7 +197,7 @@ fi
bazel_cache="$HOME/.cache/bazel" bazel_cache="$HOME/.cache/bazel"
size=$(dir_size_kb "$bazel_cache") size=$(dir_size_kb "$bazel_cache")
report "Bazel cache" "$size" report "Bazel cache" "$size"
if (( size > 0 )); then if ((size > 0)); then
try_clean "Clear bazel cache?" rm -rf "${bazel_cache:?}" try_clean "Clear bazel cache?" rm -rf "${bazel_cache:?}"
fi fi
@ -210,7 +210,7 @@ for browser_cache in \
if [[ -d "$browser_cache" ]]; then if [[ -d "$browser_cache" ]]; then
name=$(basename "$browser_cache") name=$(basename "$browser_cache")
size=$(dir_size_kb "$browser_cache") size=$(dir_size_kb "$browser_cache")
if (( size > 102400 )); then if ((size > 102400)); then
report "Browser cache: $name" "$size" "close browser first" report "Browser cache: $name" "$size" "close browser first"
fi fi
fi fi
@ -220,7 +220,7 @@ done
if command -v docker &>/dev/null; then if command -v docker &>/dev/null; then
docker_dir="/var/lib/docker" docker_dir="/var/lib/docker"
size=$(dir_size_kb "$docker_dir" || echo 0) size=$(dir_size_kb "$docker_dir" || echo 0)
if (( size > 1048576 )); then if ((size > 1048576)); then
report "Docker (images+build cache)" "$size" "docker system prune -a" report "Docker (images+build cache)" "$size" "docker system prune -a"
try_clean "Docker system prune (removes all unused)?" \ try_clean "Docker system prune (removes all unused)?" \
docker system prune -af --volumes docker system prune -af --volumes
@ -233,9 +233,9 @@ orphan_count=0
if [[ -n "$orphans" ]]; then if [[ -n "$orphans" ]]; then
orphan_count=$(echo "$orphans" | wc -l) orphan_count=$(echo "$orphans" | wc -l)
fi fi
if (( orphan_count > 0 )); then if ((orphan_count > 0)); then
orphan_size=$(echo "$orphans" | xargs pacman -Qi 2>/dev/null \ orphan_size=$(echo "$orphans" | xargs pacman -Qi 2>/dev/null |
| awk '/Installed Size/{ awk '/Installed Size/{
size=$4; unit=$5; size=$4; unit=$5;
if (unit ~ /GiB/) total += size*1048576; if (unit ~ /GiB/) total += size*1048576;
else if (unit ~ /MiB/) total += size*1024; else if (unit ~ /MiB/) total += size*1024;
@ -246,7 +246,7 @@ if (( orphan_count > 0 )); then
if confirm "Remove $orphan_count orphan packages?"; then if confirm "Remove $orphan_count orphan packages?"; then
# shellcheck disable=SC2046 # shellcheck disable=SC2046
sudo pacman -Rns $(pacman -Qdtq) --noconfirm sudo pacman -Rns $(pacman -Qdtq) --noconfirm
printf "${GREEN} ✓ Orphans removed${RESET}\n" printf "%s ✓ Orphans removed%s\n" "${GREEN}" "${RESET}"
fi fi
fi fi
@ -258,8 +258,8 @@ if [[ "$journal_line" =~ ([0-9.]+)G ]]; then
elif [[ "$journal_line" =~ ([0-9.]+)M ]]; then elif [[ "$journal_line" =~ ([0-9.]+)M ]]; then
journal_kb=$(echo "${BASH_REMATCH[1]} * 1024 / 1" | bc) journal_kb=$(echo "${BASH_REMATCH[1]} * 1024 / 1" | bc)
fi fi
if (( journal_kb > 204800 )); then if ((journal_kb > 204800)); then
excess=$(( journal_kb - 204800 )) excess=$((journal_kb - 204800))
report "Journal logs (excess over 200M)" "$excess" "journalctl --vacuum-size=200M" report "Journal logs (excess over 200M)" "$excess" "journalctl --vacuum-size=200M"
try_clean "Vacuum journal to 200M?" \ try_clean "Vacuum journal to 200M?" \
sudo journalctl --vacuum-size=200M sudo journalctl --vacuum-size=200M
@ -271,9 +271,9 @@ if [[ -d "$aur_dir" ]]; then
old_archives_kb=$(find "$aur_dir" \ old_archives_kb=$(find "$aur_dir" \
\( -name "*.pkg.tar.zst" -o -name "*.tar.gz" -o -name "*.tar.xz" \ \( -name "*.pkg.tar.zst" -o -name "*.tar.gz" -o -name "*.tar.xz" \
-o -name "*.zip" -o -name "*.tar.bz2" \) \ -o -name "*.zip" -o -name "*.tar.bz2" \) \
-mtime +30 -printf '%k\n' 2>/dev/null \ -mtime +30 -printf '%k\n' 2>/dev/null |
| awk '{t+=$1} END{print t+0}') awk '{t+=$1} END{print t+0}')
if (( old_archives_kb > 0 )); then if ((old_archives_kb > 0)); then
report "AUR old archives (>30d)" "$old_archives_kb" \ report "AUR old archives (>30d)" "$old_archives_kb" \
"find ~/aur '*.pkg.tar.zst' -mtime +30 -delete" "find ~/aur '*.pkg.tar.zst' -mtime +30 -delete"
if confirm "Delete old AUR archives (>30 days)?"; then if confirm "Delete old AUR archives (>30 days)?"; then
@ -281,15 +281,14 @@ if [[ -d "$aur_dir" ]]; then
\( -name "*.pkg.tar.zst" -o -name "*.tar.gz" -o -name "*.tar.xz" \ \( -name "*.pkg.tar.zst" -o -name "*.tar.gz" -o -name "*.tar.xz" \
-o -name "*.zip" -o -name "*.tar.bz2" \) \ -o -name "*.zip" -o -name "*.tar.bz2" \) \
-mtime +30 -delete -mtime +30 -delete
printf "${GREEN} ✓ Old AUR archives deleted${RESET}\n" printf "%s ✓ Old AUR archives deleted%s\n" "${GREEN}" "${RESET}"
fi fi
fi fi
fi fi
# ───────────────────── report-only items ───────────────────── # ───────────────────── report-only items ─────────────────────
printf "\n${BOLD}--- Report-only (manual review needed) ---${RESET}\n" printf "\n%s--- Report-only (manual review needed) ---%s\n" "${BOLD}" "${RESET}"
for manual_entry in \ for manual_entry in \
"$HOME/Downloads/too_big:~/Downloads/too_big" \ "$HOME/Downloads/too_big:~/Downloads/too_big" \
"$HOME/Downloads:~/Downloads total" \ "$HOME/Downloads:~/Downloads total" \
@ -298,7 +297,7 @@ for manual_entry in \
dir="${manual_entry%%:*}" dir="${manual_entry%%:*}"
label="${manual_entry#*:}" label="${manual_entry#*:}"
size=$(dir_size_kb "$dir") size=$(dir_size_kb "$dir")
if (( size > 0 )); then if ((size > 0)); then
hr=$(human_readable "$size") hr=$(human_readable "$size")
printf "${RED}%-40s %10s${RESET} ${CYAN}(review manually)${RESET}\n" \ printf "${RED}%-40s %10s${RESET} ${CYAN}(review manually)${RESET}\n" \
"$label" "$hr" "$label" "$hr"
@ -311,20 +310,20 @@ printf "\n%s\n" "$(printf '%.0s─' {1..60})"
total_hr=$(human_readable "$TOTAL_RECLAIMABLE") total_hr=$(human_readable "$TOTAL_RECLAIMABLE")
printf "${BOLD}Total auto-reclaimable: ${GREEN}%s${RESET}\n" "$total_hr" printf "${BOLD}Total auto-reclaimable: ${GREEN}%s${RESET}\n" "$total_hr"
read -r used_kb total_kb used_pct <<< "$(df -k / | awk 'NR==2{print $3, $2, $5}')" read -r used_kb total_kb used_pct <<<"$(df -k / | awk 'NR==2{print $3, $2, $5}')"
used_pct="${used_pct%\%}" used_pct="${used_pct%\%}"
printf "${BOLD}Current usage: %s / %s (%s%%)${RESET}\n" \ printf "${BOLD}Current usage: %s / %s (%s%%)${RESET}\n" \
"$(human_readable "$used_kb")" "$(human_readable "$total_kb")" "$used_pct" "$(human_readable "$used_kb")" "$(human_readable "$total_kb")" "$used_pct"
if (( TOTAL_RECLAIMABLE > 0 )); then if ((TOTAL_RECLAIMABLE > 0)); then
new_used=$(( used_kb - TOTAL_RECLAIMABLE )) new_used=$((used_kb - TOTAL_RECLAIMABLE))
if (( new_used < 0 )); then new_used=0; fi if ((new_used < 0)); then new_used=0; fi
new_pct=$(( new_used * 100 / total_kb )) new_pct=$((new_used * 100 / total_kb))
printf "${BOLD}After cleanup: %s / %s (%d%%)${RESET}\n" \ printf "${BOLD}After cleanup: %s / %s (%d%%)${RESET}\n" \
"$(human_readable "$new_used")" "$(human_readable "$total_kb")" "$new_pct" "$(human_readable "$new_used")" "$(human_readable "$total_kb")" "$new_pct"
fi fi
if ! $CLEAN; then if ! $CLEAN; then
printf "\n${CYAN}Run with --clean to interactively clean each category.${RESET}\n" printf "\n%sRun with --clean to interactively clean each category.%s\n" "${CYAN}" "${RESET}"
fi fi
printf "\n" printf "\n"

View File

@ -15,6 +15,11 @@ SCRIPT_DIR="${FOCUS_MODE_SCRIPT_DIR:-$(cd "$(dirname "$0")" && pwd)}"
if [ ! -f "$SCRIPT_DIR/config_secrets.sh" ] && [ -f "/data/local/tmp/focus_mode/config_secrets.sh" ]; then if [ ! -f "$SCRIPT_DIR/config_secrets.sh" ] && [ -f "/data/local/tmp/focus_mode/config_secrets.sh" ]; then
SCRIPT_DIR="/data/local/tmp/focus_mode" SCRIPT_DIR="/data/local/tmp/focus_mode"
fi fi
# config_secrets.sh is deliberately untracked (it holds secrets, see
# .gitignore) and is created per-machine / pushed to the phone at deploy
# time. It does not exist in a clean checkout, so the linter cannot follow
# it there -- which is exactly what CI lints.
# shellcheck disable=SC1091
. "$SCRIPT_DIR/config_secrets.sh" . "$SCRIPT_DIR/config_secrets.sh"
# --- Radius in meters --- # --- Radius in meters ---

View File

@ -17,12 +17,16 @@ set -euo pipefail
PHONE_IP="${1:-}" PHONE_IP="${1:-}"
ACTION="${2:---deploy}" ACTION="${2:---deploy}"
REMOTE_DIR="/data/local/tmp/focus_mode" REMOTE_DIR="/data/local/tmp/focus_mode"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # Deliberately NOT named SCRIPT_DIR: config.sh (sourced below, at top
# level) assigns SCRIPT_DIR itself and may repoint it at the on-device
# path /data/local/tmp/focus_mode. Every path here must stay anchored to
# the checkout deploy.sh runs from.
DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)"
NEEDS_GPS_FETCH=0 # set to 1 by check_coords when local coords are placeholder NEEDS_GPS_FETCH=0 # set to 1 by check_coords when local coords are placeholder
# Source shared config constants (BROWSER_PACKAGES, REMOTE_DIR, etc.) # Source shared config constants (BROWSER_PACKAGES, REMOTE_DIR, etc.)
# shellcheck source=config.sh # shellcheck source=config.sh
. "$SCRIPT_DIR/config.sh" . "$DEPLOY_DIR/config.sh"
ADB_TARGET=() ADB_TARGET=()
@ -84,8 +88,8 @@ check_adb() {
check_coords() { check_coords() {
local lat lon local lat lon
lat="$(grep '^.*HOME_LAT=' "$SCRIPT_DIR/config.sh" "$SCRIPT_DIR/config_secrets.sh" 2>/dev/null | tail -1 | cut -d'"' -f2)" lat="$(grep '^.*HOME_LAT=' "$DEPLOY_DIR/config.sh" "$DEPLOY_DIR/config_secrets.sh" 2>/dev/null | tail -1 | cut -d'"' -f2)"
lon="$(grep '^.*HOME_LON=' "$SCRIPT_DIR/config.sh" "$SCRIPT_DIR/config_secrets.sh" 2>/dev/null | tail -1 | cut -d'"' -f2)" lon="$(grep '^.*HOME_LON=' "$DEPLOY_DIR/config.sh" "$DEPLOY_DIR/config_secrets.sh" 2>/dev/null | tail -1 | cut -d'"' -f2)"
if [ "$lat" = "0.000000" ] && [ "$lon" = "0.000000" ]; then if [ "$lat" = "0.000000" ] && [ "$lon" = "0.000000" ]; then
echo "ERROR: Home coordinates not set (all zeros)." echo "ERROR: Home coordinates not set (all zeros)."
exit 1 exit 1
@ -176,10 +180,10 @@ fetch_home_coords_from_phone() {
waited=$((waited + 3)) waited=$((waited + 3))
# Format on Android 10+: " last location=Location[fused LAT,LON ...]" # Format on Android 10+: " last location=Location[fused LAT,LON ...]"
local raw local raw
raw="$(adb_cmd shell dumpsys location 2>/dev/null \ raw="$(adb_cmd shell dumpsys location 2>/dev/null |
| grep 'last location=Location\[' \ grep 'last location=Location\[' |
| grep -oE '[+-]?[0-9]+\.[0-9]+,[+-]?[0-9]+\.[0-9]+' \ grep -oE '[+-]?[0-9]+\.[0-9]+,[+-]?[0-9]+\.[0-9]+' |
| head -1 || true)" head -1 || true)"
[[ -n "$raw" ]] && coords="$raw" [[ -n "$raw" ]] && coords="$raw"
printf '.' >&2 printf '.' >&2
done done
@ -367,23 +371,23 @@ do_deploy() {
adb_root "chmod 777 /data/local/tmp/focus_stage" adb_root "chmod 777 /data/local/tmp/focus_stage"
echo "[4/7] Uploading scripts..." echo "[4/7] Uploading scripts..."
adb_cmd push "$SCRIPT_DIR/config.sh" "/data/local/tmp/focus_stage/config.sh" adb_cmd push "$DEPLOY_DIR/config.sh" "/data/local/tmp/focus_stage/config.sh"
adb_cmd push "$SCRIPT_DIR/focus_daemon.sh" "/data/local/tmp/focus_stage/focus_daemon.sh" adb_cmd push "$DEPLOY_DIR/focus_daemon.sh" "/data/local/tmp/focus_stage/focus_daemon.sh"
adb_cmd push "$SCRIPT_DIR/focus_ctl.sh" "/data/local/tmp/focus_stage/focus_ctl.sh" adb_cmd push "$DEPLOY_DIR/focus_ctl.sh" "/data/local/tmp/focus_stage/focus_ctl.sh"
adb_cmd push "$SCRIPT_DIR/hosts_enforcer.sh" "/data/local/tmp/focus_stage/hosts_enforcer.sh" adb_cmd push "$DEPLOY_DIR/hosts_enforcer.sh" "/data/local/tmp/focus_stage/hosts_enforcer.sh"
adb_cmd push "$SCRIPT_DIR/dns_enforcer.sh" "/data/local/tmp/focus_stage/dns_enforcer.sh" adb_cmd push "$DEPLOY_DIR/dns_enforcer.sh" "/data/local/tmp/focus_stage/dns_enforcer.sh"
adb_cmd push "$SCRIPT_DIR/launcher_enforcer.sh" "/data/local/tmp/focus_stage/launcher_enforcer.sh" adb_cmd push "$DEPLOY_DIR/launcher_enforcer.sh" "/data/local/tmp/focus_stage/launcher_enforcer.sh"
adb_cmd push "$SCRIPT_DIR/curfew_enforcer.sh" "/data/local/tmp/focus_stage/curfew_enforcer.sh" adb_cmd push "$DEPLOY_DIR/curfew_enforcer.sh" "/data/local/tmp/focus_stage/curfew_enforcer.sh"
adb_cmd push "$SCRIPT_DIR/tether_enforcer.sh" "/data/local/tmp/focus_stage/tether_enforcer.sh" adb_cmd push "$DEPLOY_DIR/tether_enforcer.sh" "/data/local/tmp/focus_stage/tether_enforcer.sh"
adb_cmd push "$SCRIPT_DIR/workout_detector.sh" "/data/local/tmp/focus_stage/workout_detector.sh" adb_cmd push "$DEPLOY_DIR/workout_detector.sh" "/data/local/tmp/focus_stage/workout_detector.sh"
adb_cmd push "$SCRIPT_DIR/magisk_service.sh" "/data/local/tmp/focus_stage/99-focus-mode.sh" adb_cmd push "$DEPLOY_DIR/magisk_service.sh" "/data/local/tmp/focus_stage/99-focus-mode.sh"
# ---- sqlite3 binary for workout_detector.sh ---- # ---- sqlite3 binary for workout_detector.sh ----
# Stored outside the repo (binary-files policy). Built once via the NDK # Stored outside the repo (binary-files policy). Built once via the NDK
# against the SQLite amalgamation; see workout_detector.sh comments for # against the SQLite amalgamation; see workout_detector.sh comments for
# the recipe. ~1.6 MB stripped, aarch64, PIE, dynamically linked against # the recipe. ~1.6 MB stripped, aarch64, PIE, dynamically linked against
# bionic (Android 30+). # bionic (Android 30+).
SQLITE3_BIN="$SCRIPT_DIR/../../testsAndMisc_binaries/phone_focus_mode/sqlite3" SQLITE3_BIN="$DEPLOY_DIR/../../testsAndMisc_binaries/phone_focus_mode/sqlite3"
if [ -f "$SQLITE3_BIN" ]; then if [ -f "$SQLITE3_BIN" ]; then
echo " Uploading sqlite3 binary ($(stat -c%s "$SQLITE3_BIN") bytes)..." echo " Uploading sqlite3 binary ($(stat -c%s "$SQLITE3_BIN") bytes)..."
adb_cmd push "$SQLITE3_BIN" "/data/local/tmp/focus_stage/sqlite3" adb_cmd push "$SQLITE3_BIN" "/data/local/tmp/focus_stage/sqlite3"
@ -394,7 +398,7 @@ do_deploy() {
# Generate and upload the canonical hosts file (StevenBlack + custom entries). # Generate and upload the canonical hosts file (StevenBlack + custom entries).
# This mirrors what linux_configuration/hosts/install.sh installs on the PC. # This mirrors what linux_configuration/hosts/install.sh installs on the PC.
HOSTS_GENERATOR="$SCRIPT_DIR/../linux_configuration/scripts/periodic_background/hosts/generate_hosts_file.sh" HOSTS_GENERATOR="$DEPLOY_DIR/../linux_configuration/scripts/periodic_background/hosts/generate_hosts_file.sh"
if [ -f "$HOSTS_GENERATOR" ]; then if [ -f "$HOSTS_GENERATOR" ]; then
chmod +x "$HOSTS_GENERATOR" 2>/dev/null || true chmod +x "$HOSTS_GENERATOR" 2>/dev/null || true
echo " Generating canonical hosts file..." echo " Generating canonical hosts file..."
@ -402,8 +406,8 @@ do_deploy() {
HOSTS_SHA_TMP="$(mktemp)" HOSTS_SHA_TMP="$(mktemp)"
if bash "$HOSTS_GENERATOR" "$HOSTS_TMP"; then if bash "$HOSTS_GENERATOR" "$HOSTS_TMP"; then
hosts_hash="$(compute_file_hash "$HOSTS_TMP")" hosts_hash="$(compute_file_hash "$HOSTS_TMP")"
printf '%s\n' "$hosts_hash" > "$HOSTS_SHA_TMP" printf '%s\n' "$hosts_hash" >"$HOSTS_SHA_TMP"
echo " Uploading canonical hosts ($(wc -l < "$HOSTS_TMP") lines)..." echo " Uploading canonical hosts ($(wc -l <"$HOSTS_TMP") lines)..."
adb_cmd push "$HOSTS_TMP" "/data/local/tmp/focus_stage/hosts.canonical" adb_cmd push "$HOSTS_TMP" "/data/local/tmp/focus_stage/hosts.canonical"
adb_cmd push "$HOSTS_SHA_TMP" "/data/local/tmp/focus_stage/hosts.sha256" adb_cmd push "$HOSTS_SHA_TMP" "/data/local/tmp/focus_stage/hosts.sha256"
@ -417,10 +421,13 @@ do_deploy() {
# so the generator and the runtime always agree on the domain set. # so the generator and the runtime always agree on the domain set.
UNBLOCK_DOMAINS="$( UNBLOCK_DOMAINS="$(
# shellcheck disable=SC1091 # shellcheck disable=SC1091
( . "$SCRIPT_DIR/config.sh" >/dev/null 2>&1; printf '%s\n' "$WORKOUT_UNBLOCK_DOMAINS" ) \ (
| sed 's/[[:space:]]\{1,\}/\n/g' \ . "$DEPLOY_DIR/config.sh" >/dev/null 2>&1
| grep -vE '^[[:space:]]*(#|$)' \ printf '%s\n' "$WORKOUT_UNBLOCK_DOMAINS"
| sort -u ) |
sed 's/[[:space:]]\{1,\}/\n/g' |
grep -vE '^[[:space:]]*(#|$)' |
sort -u
)" )"
if [ -n "$UNBLOCK_DOMAINS" ]; then if [ -n "$UNBLOCK_DOMAINS" ]; then
# Build an awk regex of exact-match domains anchored as the # Build an awk regex of exact-match domains anchored as the
@ -428,11 +435,11 @@ do_deploy() {
# followed by aliases). We strip any line whose first non-IP # followed by aliases). We strip any line whose first non-IP
# token matches one of the unblock domains. # token matches one of the unblock domains.
WORKOUT_UNBLOCK_DOMAINS="$UNBLOCK_DOMAINS" \ WORKOUT_UNBLOCK_DOMAINS="$UNBLOCK_DOMAINS" \
python3 "$SCRIPT_DIR/strip_workout_hosts.py" "$HOSTS_TMP" "$HOSTS_WORKOUT_TMP" \ python3 "$DEPLOY_DIR/strip_workout_hosts.py" "$HOSTS_TMP" "$HOSTS_WORKOUT_TMP" ||
|| cp "$HOSTS_TMP" "$HOSTS_WORKOUT_TMP" cp "$HOSTS_TMP" "$HOSTS_WORKOUT_TMP"
workout_hash="$(compute_file_hash "$HOSTS_WORKOUT_TMP")" workout_hash="$(compute_file_hash "$HOSTS_WORKOUT_TMP")"
printf '%s\n' "$workout_hash" > "$HOSTS_WORKOUT_SHA_TMP" printf '%s\n' "$workout_hash" >"$HOSTS_WORKOUT_SHA_TMP"
stripped_lines=$(($(wc -l < "$HOSTS_TMP") - $(wc -l < "$HOSTS_WORKOUT_TMP"))) stripped_lines=$(($(wc -l <"$HOSTS_TMP") - $(wc -l <"$HOSTS_WORKOUT_TMP")))
echo " Uploading workout-variant hosts (stripped $stripped_lines YouTube lines)..." echo " Uploading workout-variant hosts (stripped $stripped_lines YouTube lines)..."
adb_cmd push "$HOSTS_WORKOUT_TMP" "/data/local/tmp/focus_stage/hosts.canonical.workout" adb_cmd push "$HOSTS_WORKOUT_TMP" "/data/local/tmp/focus_stage/hosts.canonical.workout"
adb_cmd push "$HOSTS_WORKOUT_SHA_TMP" "/data/local/tmp/focus_stage/hosts.sha256.workout" adb_cmd push "$HOSTS_WORKOUT_SHA_TMP" "/data/local/tmp/focus_stage/hosts.sha256.workout"
@ -475,7 +482,7 @@ do_deploy() {
fi fi
else else
echo " Pushing config_secrets.sh (first install)..." echo " Pushing config_secrets.sh (first install)..."
adb_cmd push "$SCRIPT_DIR/config_secrets.sh" "/data/local/tmp/focus_stage/config_secrets.sh" adb_cmd push "$DEPLOY_DIR/config_secrets.sh" "/data/local/tmp/focus_stage/config_secrets.sh"
adb_root "cp /data/local/tmp/focus_stage/config_secrets.sh $REMOTE_DIR/config_secrets.sh" adb_root "cp /data/local/tmp/focus_stage/config_secrets.sh $REMOTE_DIR/config_secrets.sh"
fi fi
@ -493,7 +500,7 @@ do_deploy() {
adb_root "cp /data/local/tmp/focus_stage/sqlite3 $REMOTE_DIR/sqlite3" adb_root "cp /data/local/tmp/focus_stage/sqlite3 $REMOTE_DIR/sqlite3"
adb_root "chmod 0755 $REMOTE_DIR/sqlite3" adb_root "chmod 0755 $REMOTE_DIR/sqlite3"
fi fi
if grep -q '^export FOCUS_BOOT_AUTOSTART=1' "$SCRIPT_DIR/config.sh"; then if grep -q '^export FOCUS_BOOT_AUTOSTART=1' "$DEPLOY_DIR/config.sh"; then
adb_root "cp /data/local/tmp/focus_stage/99-focus-mode.sh /data/adb/service.d/99-focus-mode.sh" adb_root "cp /data/local/tmp/focus_stage/99-focus-mode.sh /data/adb/service.d/99-focus-mode.sh"
else else
adb_root "rm -f /data/adb/service.d/99-focus-mode.sh /data/adb/service.d/99-focus-mode.sh.disabled" adb_root "rm -f /data/adb/service.d/99-focus-mode.sh /data/adb/service.d/99-focus-mode.sh.disabled"
@ -555,6 +562,12 @@ do_deploy() {
# Flush in-process DNS caches of browsers. Apps like Firefox and Chrome # Flush in-process DNS caches of browsers. Apps like Firefox and Chrome
# cache resolved IPs internally and bypass /etc/hosts until restarted. # cache resolved IPs internally and bypass /etc/hosts until restarted.
echo " Flushing browser DNS caches..." echo " Flushing browser DNS caches..."
# BROWSER_PACKAGES is set by the top-level `. config.sh` above and is never
# modified. shellcheck only flags it because config.sh is ALSO re-sourced
# inside a subshell further up (to re-read WORKOUT_UNBLOCK_DOMAINS from the
# freshly-staged copy); that subshell's assignments are discarded by design.
# The unquoted expansion is the intended word split over the package list.
# shellcheck disable=SC2031
for _pkg in $BROWSER_PACKAGES; do for _pkg in $BROWSER_PACKAGES; do
[ -n "$_pkg" ] || continue [ -n "$_pkg" ] || continue
adb_root "am force-stop '$_pkg' 2>/dev/null; true" adb_root "am force-stop '$_pkg' 2>/dev/null; true"
@ -575,7 +588,7 @@ do_deploy() {
echo "[5/7] Setting permissions..." echo "[5/7] Setting permissions..."
adb_root "chmod 755 $REMOTE_DIR/config.sh $REMOTE_DIR/focus_daemon.sh $REMOTE_DIR/focus_ctl.sh $REMOTE_DIR/hosts_enforcer.sh $REMOTE_DIR/dns_enforcer.sh $REMOTE_DIR/launcher_enforcer.sh $REMOTE_DIR/curfew_enforcer.sh $REMOTE_DIR/tether_enforcer.sh $REMOTE_DIR/workout_detector.sh" || true adb_root "chmod 755 $REMOTE_DIR/config.sh $REMOTE_DIR/focus_daemon.sh $REMOTE_DIR/focus_ctl.sh $REMOTE_DIR/hosts_enforcer.sh $REMOTE_DIR/dns_enforcer.sh $REMOTE_DIR/launcher_enforcer.sh $REMOTE_DIR/curfew_enforcer.sh $REMOTE_DIR/tether_enforcer.sh $REMOTE_DIR/workout_detector.sh" || true
if grep -q '^export FOCUS_BOOT_AUTOSTART=1' "$SCRIPT_DIR/config.sh"; then if grep -q '^export FOCUS_BOOT_AUTOSTART=1' "$DEPLOY_DIR/config.sh"; then
adb_root "chmod 755 /data/adb/service.d/99-focus-mode.sh" adb_root "chmod 755 /data/adb/service.d/99-focus-mode.sh"
fi fi
adb_root "touch $REMOTE_DIR/disabled_by_focus.txt $REMOTE_DIR/focus_mode.log $REMOTE_DIR/hosts_enforcer.log $REMOTE_DIR/dns_enforcer.log $REMOTE_DIR/launcher_enforcer.log $REMOTE_DIR/tether_enforcer.log $REMOTE_DIR/workout_detector.log" adb_root "touch $REMOTE_DIR/disabled_by_focus.txt $REMOTE_DIR/focus_mode.log $REMOTE_DIR/hosts_enforcer.log $REMOTE_DIR/dns_enforcer.log $REMOTE_DIR/launcher_enforcer.log $REMOTE_DIR/tether_enforcer.log $REMOTE_DIR/workout_detector.log"
@ -645,7 +658,7 @@ do_deploy() {
sleep 10 sleep 10
# ---- Companion status notification app ---- # ---- Companion status notification app ----
APP_DIR="$SCRIPT_DIR/focus_status_app" APP_DIR="$DEPLOY_DIR/focus_status_app"
APK="$APP_DIR/build/focus_status.apk" APK="$APP_DIR/build/focus_status.apk"
if [ -d "$APP_DIR" ]; then if [ -d "$APP_DIR" ]; then
echo "[7/7] Building & installing companion status-notification app..." echo "[7/7] Building & installing companion status-notification app..."
@ -679,8 +692,8 @@ do_deploy() {
adb_cmd shell pm grant com.kuhy.focusstatus android.permission.POST_NOTIFICATIONS >/dev/null 2>&1 || true adb_cmd shell pm grant com.kuhy.focusstatus android.permission.POST_NOTIFICATIONS >/dev/null 2>&1 || true
# Pre-approve Magisk SU so the app never shows the approval prompt. # Pre-approve Magisk SU so the app never shows the approval prompt.
APP_UID="$( APP_UID="$(
adb_cmd shell dumpsys package com.kuhy.focusstatus 2>/dev/null \ adb_cmd shell dumpsys package com.kuhy.focusstatus 2>/dev/null |
| awk 'match($0, /userId=[0-9]+/) {print substr($0, RSTART + 7, RLENGTH - 7); exit}' awk 'match($0, /userId=[0-9]+/) {print substr($0, RSTART + 7, RLENGTH - 7); exit}'
)" )"
if [ -n "$APP_UID" ]; then if [ -n "$APP_UID" ]; then
adb_cmd shell "su -c 'magisk --sqlite \"INSERT OR REPLACE INTO policies (uid,policy,until,logging,notification) VALUES ($APP_UID,2,0,1,1)\"'" >/dev/null 2>&1 || true adb_cmd shell "su -c 'magisk --sqlite \"INSERT OR REPLACE INTO policies (uid,policy,until,logging,notification) VALUES ($APP_UID,2,0,1,1)\"'" >/dev/null 2>&1 || true
@ -786,23 +799,35 @@ check_adb
check_ip check_ip
case "$ACTION" in case "$ACTION" in
--deploy|"") do_deploy ;; --deploy | "") do_deploy ;;
--status) do_control "status" ;; --status) do_control "status" ;;
--log) connect_adb; adb_root "sh $REMOTE_DIR/focus_ctl.sh log 100" ;; --log)
--stop) do_control "stop" ;; connect_adb
--start) do_control "start" ;; adb_root "sh $REMOTE_DIR/focus_ctl.sh log 100"
--restart) do_control "restart" ;; ;;
--enable) do_control "enable" ;; --stop) do_control "stop" ;;
--disable) do_control "disable" ;; --start) do_control "start" ;;
--list) do_control "list-apps" ;; --restart) do_control "restart" ;;
--pull-log) do_pull_log ;; --enable) do_control "enable" ;;
--find-pkg) do_find_pkg "$@" ;; --disable) do_control "disable" ;;
--hosts-status) do_control "hosts-status" ;; --list) do_control "list-apps" ;;
--hosts-log) connect_adb; adb_root "sh $REMOTE_DIR/focus_ctl.sh hosts-log 100" ;; --pull-log) do_pull_log ;;
--launcher-status) do_control "launcher-status" ;; --find-pkg) do_find_pkg "$@" ;;
--launcher-log) connect_adb; adb_root "sh $REMOTE_DIR/focus_ctl.sh launcher-log 100" ;; --hosts-status) do_control "hosts-status" ;;
--capture-coords) do_capture_coords ;; --hosts-log)
--snapshot-launcher) do_snapshot_launcher ;; connect_adb
--install-aurora) do_install_aurora ;; adb_root "sh $REMOTE_DIR/focus_ctl.sh hosts-log 100"
*) echo "Unknown action: $ACTION"; usage ;; ;;
--launcher-status) do_control "launcher-status" ;;
--launcher-log)
connect_adb
adb_root "sh $REMOTE_DIR/focus_ctl.sh launcher-log 100"
;;
--capture-coords) do_capture_coords ;;
--snapshot-launcher) do_snapshot_launcher ;;
--install-aurora) do_install_aurora ;;
*)
echo "Unknown action: $ACTION"
usage
;;
esac esac

View File

@ -38,15 +38,15 @@ chmod 666 "$DNS_LOG" 2>/dev/null || true
log() { log() {
local ts local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')" ts="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$ts] $1" >> "$DNS_LOG" echo "[$ts] $1" >>"$DNS_LOG"
} }
rotate_log() { rotate_log() {
local lines local lines
lines="$(wc -l < "$DNS_LOG" 2>/dev/null || echo 0)" lines="$(wc -l <"$DNS_LOG" 2>/dev/null || echo 0)"
if [ "$lines" -gt 500 ]; then if [ "$lines" -gt 500 ]; then
local tmp="$DNS_LOG.tmp" local tmp="$DNS_LOG.tmp"
tail -n 500 "$DNS_LOG" > "$tmp" tail -n 500 "$DNS_LOG" >"$tmp"
mv "$tmp" "$DNS_LOG" mv "$tmp" "$DNS_LOG"
fi fi
} }
@ -57,7 +57,7 @@ acquire_lock() {
old_pid="$(cat "$PIDFILE")" old_pid="$(cat "$PIDFILE")"
if kill -0 "$old_pid" 2>/dev/null; then if kill -0 "$old_pid" 2>/dev/null; then
local cmdline local cmdline
cmdline="$(tr '\0' ' ' < "/proc/$old_pid/cmdline" 2>/dev/null)" cmdline="$(tr '\0' ' ' <"/proc/$old_pid/cmdline" 2>/dev/null)"
if echo "$cmdline" | grep -q "dns_enforcer"; then if echo "$cmdline" | grep -q "dns_enforcer"; then
echo "dns_enforcer already running (PID $old_pid)" echo "dns_enforcer already running (PID $old_pid)"
exit 0 exit 0
@ -65,7 +65,7 @@ acquire_lock() {
fi fi
rm -f "$PIDFILE" rm -f "$PIDFILE"
fi fi
echo $$ > "$PIDFILE" echo $$ >"$PIDFILE"
} }
# ---- Private DNS ---- # ---- Private DNS ----
@ -198,6 +198,10 @@ chain_intact() {
enforce_iptables() { enforce_iptables() {
if command -v iptables >/dev/null 2>&1; then if command -v iptables >/dev/null 2>&1; then
# Intentional word split: expected_rule_count() iterates "$@" over the
# configured IP list. This runs under /system/bin/sh on the phone, where
# arrays do not exist, so an unquoted expansion is the POSIX way to do it.
# shellcheck disable=SC2086
if chain_intact iptables "$(expected_rule_count $DNS_DOH_IPV4)"; then if chain_intact iptables "$(expected_rule_count $DNS_DOH_IPV4)"; then
: :
elif ensure_chain iptables && fill_chain_v4; then elif ensure_chain iptables && fill_chain_v4; then
@ -205,6 +209,8 @@ enforce_iptables() {
fi fi
fi fi
if command -v ip6tables >/dev/null 2>&1; then if command -v ip6tables >/dev/null 2>&1; then
# Intentional word split, as above.
# shellcheck disable=SC2086
if chain_intact ip6tables "$(expected_rule_count $DNS_DOH_IPV6)"; then if chain_intact ip6tables "$(expected_rule_count $DNS_DOH_IPV6)"; then
: :
elif ensure_chain ip6tables && fill_chain_v6; then elif ensure_chain ip6tables && fill_chain_v6; then

View File

@ -18,7 +18,7 @@ PIDFILE="$STATE_DIR/daemon.pid"
log() { log() {
local ts local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')" ts="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$ts] $1" >> "$LOG_FILE" echo "[$ts] $1" >>"$LOG_FILE"
} }
# Emit one valid package name per line from WHITELIST. # Emit one valid package name per line from WHITELIST.
@ -28,10 +28,14 @@ iter_whitelist_packages() {
printf '%s\n' "$WHITELIST" | while IFS= read -r line; do printf '%s\n' "$WHITELIST" | while IFS= read -r line; do
line="$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" line="$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
case "$line" in case "$line" in
""|\#*) continue ;; "" | \#*) continue ;;
esac esac
# Keep first token only; ignore any inline prose if present. # Keep first token only; ignore any inline prose if present.
# Intentional word split to grab the first token. This runs under
# /system/bin/sh on the phone, where arrays do not exist, so an
# unquoted expansion is the POSIX way to do it.
# shellcheck disable=SC2086
set -- $line set -- $line
pkg="$1" pkg="$1"
@ -157,10 +161,10 @@ cmd_status() {
echo "" echo ""
# Show current location if available # Show current location if available
location="$(dumpsys location 2>/dev/null \ location="$(dumpsys location 2>/dev/null |
| grep -oE 'Location\[.*[-]?[0-9]{1,3}\.[0-9]+,[-]?[0-9]{1,3}\.[0-9]+' \ grep -oE 'Location\[.*[-]?[0-9]{1,3}\.[0-9]+,[-]?[0-9]{1,3}\.[0-9]+' |
| grep -oE '[-]?[0-9]{1,3}\.[0-9]+,[-]?[0-9]{1,3}\.[0-9]+' \ grep -oE '[-]?[0-9]{1,3}\.[0-9]+,[-]?[0-9]{1,3}\.[0-9]+' |
| head -1)" head -1)"
if [ -n "$location" ]; then if [ -n "$location" ]; then
lat="$(echo "$location" | cut -d',' -f1)" lat="$(echo "$location" | cut -d',' -f1)"
@ -194,7 +198,7 @@ cmd_enable() {
. "$SCRIPT_DIR/config.sh" . "$SCRIPT_DIR/config.sh"
# Source common functions - inline here for standalone use # Source common functions - inline here for standalone use
: > "$STATE_DIR/disabled_by_focus.txt" : >"$STATE_DIR/disabled_by_focus.txt"
local count=0 local count=0
for pkg in $(pm list packages -3 2>/dev/null | sed 's/^package://'); do for pkg in $(pm list packages -3 2>/dev/null | sed 's/^package://'); do
# Check whitelist # Check whitelist
@ -202,7 +206,10 @@ cmd_enable() {
for w in $(iter_whitelist_packages); do for w in $(iter_whitelist_packages); do
w_clean="$(echo "$w" | tr -d '[:space:]')" w_clean="$(echo "$w" | tr -d '[:space:]')"
[ -z "$w_clean" ] && continue [ -z "$w_clean" ] && continue
[ "$pkg" = "$w_clean" ] && { whitelisted=1; break; } [ "$pkg" = "$w_clean" ] && {
whitelisted=1
break
}
done done
[ "$whitelisted" -eq 1 ] && continue [ "$whitelisted" -eq 1 ] && continue
@ -212,17 +219,20 @@ cmd_enable() {
prefix_clean="$(echo "$prefix" | tr -d '[:space:]')" prefix_clean="$(echo "$prefix" | tr -d '[:space:]')"
[ -z "$prefix_clean" ] && continue [ -z "$prefix_clean" ] && continue
case "$pkg" in case "$pkg" in
"$prefix_clean"*) protected=1; break ;; "$prefix_clean"*)
protected=1
break
;;
esac esac
done done
[ "$protected" -eq 1 ] && continue [ "$protected" -eq 1 ] && continue
if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then
echo "$pkg" >> "$STATE_DIR/disabled_by_focus.txt" echo "$pkg" >>"$STATE_DIR/disabled_by_focus.txt"
count=$((count + 1)) count=$((count + 1))
fi fi
done done
echo "focus" > "$MODE_FILE" echo "focus" >"$MODE_FILE"
echo "Done: disabled $count apps" echo "Done: disabled $count apps"
} }
@ -260,13 +270,13 @@ cmd_disable() {
while IFS= read -r pkg; do while IFS= read -r pkg; do
[ -z "$pkg" ] && continue [ -z "$pkg" ] && continue
pm enable "$pkg" >/dev/null 2>&1 && count=$((count + 1)) pm enable "$pkg" >/dev/null 2>&1 && count=$((count + 1))
done < "$DISABLED_APPS_FILE" done <"$DISABLED_APPS_FILE"
: > "$DISABLED_APPS_FILE" : >"$DISABLED_APPS_FILE"
echo "Done: re-enabled $count apps" echo "Done: re-enabled $count apps"
else else
echo "No apps to re-enable" echo "No apps to re-enable"
fi fi
echo "normal" > "$MODE_FILE" echo "normal" >"$MODE_FILE"
} }
cmd_log() { cmd_log() {
@ -285,7 +295,10 @@ cmd_list_apps() {
for w in $(iter_whitelist_packages); do for w in $(iter_whitelist_packages); do
w="$(echo "$w" | tr -d '[:space:]')" w="$(echo "$w" | tr -d '[:space:]')"
[ -z "$w" ] && continue [ -z "$w" ] && continue
[ "$pkg" = "$w" ] && { whitelisted=1; break; } [ "$pkg" = "$w" ] && {
whitelisted=1
break
}
done done
if [ "$whitelisted" -eq 0 ]; then if [ "$whitelisted" -eq 0 ]; then
# Check if currently disabled by focus mode # Check if currently disabled by focus mode
@ -550,7 +563,7 @@ cmd_launcher_snapshot() {
chattr -i "$LAUNCHER_APK" "$LAUNCHER_SHA_FILE" "$LAUNCHER_ACTIVITY_FILE" 2>/dev/null || true chattr -i "$LAUNCHER_APK" "$LAUNCHER_SHA_FILE" "$LAUNCHER_ACTIVITY_FILE" 2>/dev/null || true
cp "$apk_path" "$LAUNCHER_APK" || return 1 cp "$apk_path" "$LAUNCHER_APK" || return 1
chmod 644 "$LAUNCHER_APK" chmod 644 "$LAUNCHER_APK"
sha256sum "$LAUNCHER_APK" | awk '{print $1}' > "$LAUNCHER_SHA_FILE" sha256sum "$LAUNCHER_APK" | awk '{print $1}' >"$LAUNCHER_SHA_FILE"
chmod 644 "$LAUNCHER_SHA_FILE" chmod 644 "$LAUNCHER_SHA_FILE"
# Resolve the current HOME activity (or the launcher's default activity # Resolve the current HOME activity (or the launcher's default activity
@ -563,14 +576,14 @@ cmd_launcher_snapshot() {
# Fall back to the launcher's MAIN/LAUNCHER activity # Fall back to the launcher's MAIN/LAUNCHER activity
component="$(cmd package resolve-activity --brief \ component="$(cmd package resolve-activity --brief \
-c android.intent.category.LAUNCHER \ -c android.intent.category.LAUNCHER \
-a android.intent.action.MAIN "$LAUNCHER_PACKAGE" 2>/dev/null \ -a android.intent.action.MAIN "$LAUNCHER_PACKAGE" 2>/dev/null |
| awk 'NR==2{print}')" awk 'NR==2{print}')"
fi fi
if [ -z "$component" ]; then if [ -z "$component" ]; then
echo "ERROR: could not resolve HOME activity for $LAUNCHER_PACKAGE" echo "ERROR: could not resolve HOME activity for $LAUNCHER_PACKAGE"
return 1 return 1
fi fi
echo "$component" > "$LAUNCHER_ACTIVITY_FILE" echo "$component" >"$LAUNCHER_ACTIVITY_FILE"
chmod 644 "$LAUNCHER_ACTIVITY_FILE" chmod 644 "$LAUNCHER_ACTIVITY_FILE"
# Make snapshot immutable so even root-in-a-terminal can't overwrite # Make snapshot immutable so even root-in-a-terminal can't overwrite
@ -578,7 +591,7 @@ cmd_launcher_snapshot() {
chattr +i "$LAUNCHER_APK" "$LAUNCHER_SHA_FILE" "$LAUNCHER_ACTIVITY_FILE" 2>/dev/null || true chattr +i "$LAUNCHER_APK" "$LAUNCHER_SHA_FILE" "$LAUNCHER_ACTIVITY_FILE" 2>/dev/null || true
echo "Snapshot saved:" echo "Snapshot saved:"
echo " APK: $LAUNCHER_APK ($(wc -c < "$LAUNCHER_APK") bytes)" echo " APK: $LAUNCHER_APK ($(wc -c <"$LAUNCHER_APK") bytes)"
echo " SHA256: $(cat "$LAUNCHER_SHA_FILE")" echo " SHA256: $(cat "$LAUNCHER_SHA_FILE")"
echo " Activity: $component" echo " Activity: $component"
} }
@ -612,7 +625,7 @@ cmd_launcher_status() {
fi fi
echo "Snapshot: $LAUNCHER_APK" echo "Snapshot: $LAUNCHER_APK"
if [ -f "$LAUNCHER_APK" ]; then if [ -f "$LAUNCHER_APK" ]; then
echo "Snapshot size: $(wc -c < "$LAUNCHER_APK") bytes" echo "Snapshot size: $(wc -c <"$LAUNCHER_APK") bytes"
fi fi
if [ -s "$DISABLED_COMPETITORS_FILE" ]; then if [ -s "$DISABLED_COMPETITORS_FILE" ]; then
echo "Disabled competitors:" echo "Disabled competitors:"
@ -652,10 +665,10 @@ cmd_launcher_stop() {
if [ -s "$DISABLED_COMPETITORS_FILE" ]; then if [ -s "$DISABLED_COMPETITORS_FILE" ]; then
while read -r pkg; do while read -r pkg; do
[ -z "$pkg" ] && continue [ -z "$pkg" ] && continue
pm enable --user 0 "$pkg" >/dev/null 2>&1 && \ pm enable --user 0 "$pkg" >/dev/null 2>&1 &&
echo "Re-enabled competing launcher: $pkg" echo "Re-enabled competing launcher: $pkg"
done < "$DISABLED_COMPETITORS_FILE" done <"$DISABLED_COMPETITORS_FILE"
: > "$DISABLED_COMPETITORS_FILE" : >"$DISABLED_COMPETITORS_FILE"
fi fi
} }
@ -729,7 +742,7 @@ cmd_workout_status() {
echo "Live DB query: in-progress workouts = ${live_count:-<query failed>}" echo "Live DB query: in-progress workouts = ${live_count:-<query failed>}"
fi fi
if [ -f "$HOSTS_CANONICAL_WORKOUT" ]; then if [ -f "$HOSTS_CANONICAL_WORKOUT" ]; then
echo "Workout hosts: $HOSTS_CANONICAL_WORKOUT ($(wc -l < "$HOSTS_CANONICAL_WORKOUT" 2>/dev/null) lines)" echo "Workout hosts: $HOSTS_CANONICAL_WORKOUT ($(wc -l <"$HOSTS_CANONICAL_WORKOUT" 2>/dev/null) lines)"
else else
echo "Workout hosts: <missing — deploy.sh must regenerate it>" echo "Workout hosts: <missing — deploy.sh must regenerate it>"
fi fi
@ -768,7 +781,7 @@ cmd_workout_stop() {
fi fi
# Fail-closed on manual stop: write 0 so the hosts enforcer reverts to # Fail-closed on manual stop: write 0 so the hosts enforcer reverts to
# the full-block canonical and YouTube goes back to being blocked. # the full-block canonical and YouTube goes back to being blocked.
printf '0\n' > "$WORKOUT_ACTIVE_FILE" 2>/dev/null || true printf '0\n' >"$WORKOUT_ACTIVE_FILE" 2>/dev/null || true
chmod 666 "$WORKOUT_ACTIVE_FILE" 2>/dev/null || true chmod 666 "$WORKOUT_ACTIVE_FILE" 2>/dev/null || true
echo "workout_active flag forced to 0" echo "workout_active flag forced to 0"
} }
@ -806,8 +819,10 @@ _ctl_dec() {
ctl_is_curfew_now() { ctl_is_curfew_now() {
local now start end local now start end
now="$(date +%H%M 2>/dev/null)" now="$(date +%H%M 2>/dev/null)"
case "$now" in ''|*[!0-9]*) return 1 ;; esac case "$now" in '' | *[!0-9]*) return 1 ;; esac
now="$(_ctl_dec "$now")"; start="$(_ctl_dec "$NIGHT_CURFEW_START")"; end="$(_ctl_dec "$NIGHT_CURFEW_END")" now="$(_ctl_dec "$now")"
start="$(_ctl_dec "$NIGHT_CURFEW_START")"
end="$(_ctl_dec "$NIGHT_CURFEW_END")"
if [ "$start" -le "$end" ]; then if [ "$start" -le "$end" ]; then
[ "$now" -ge "$start" ] && [ "$now" -lt "$end" ] [ "$now" -ge "$start" ] && [ "$now" -lt "$end" ]
else else
@ -832,8 +847,8 @@ cmd_curfew_status() {
if [ -f "$MODE_FILE" ]; then if [ -f "$MODE_FILE" ]; then
echo "Focus mode: $(cat "$MODE_FILE" 2>/dev/null)" echo "Focus mode: $(cat "$MODE_FILE" 2>/dev/null)"
fi fi
[ -e "$CURFEW_ENFORCER_STATE" ] && echo "Applied now: YES (grayscale/DND locked)" \ [ -e "$CURFEW_ENFORCER_STATE" ] && echo "Applied now: YES (grayscale/DND locked)" ||
|| echo "Applied now: no" echo "Applied now: no"
echo "Grayscale: ${CURFEW_GRAYSCALE_ENABLED} DND: ${CURFEW_DND_ENABLED} Net: ${CURFEW_NET_ENABLED}" echo "Grayscale: ${CURFEW_GRAYSCALE_ENABLED} DND: ${CURFEW_DND_ENABLED} Net: ${CURFEW_NET_ENABLED}"
if iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then if iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then
echo "iptables $CURFEW_NET_IPT_CHAIN: $(iptables -S "$CURFEW_NET_IPT_CHAIN" 2>/dev/null | wc -l) rules" echo "iptables $CURFEW_NET_IPT_CHAIN: $(iptables -S "$CURFEW_NET_IPT_CHAIN" 2>/dev/null | wc -l) rules"
@ -841,7 +856,7 @@ cmd_curfew_status() {
echo "iptables $CURFEW_NET_IPT_CHAIN: absent (net curfew not applied)" echo "iptables $CURFEW_NET_IPT_CHAIN: absent (net curfew not applied)"
fi fi
if [ -f "$STATE_DIR/night_whitelist.txt" ]; then if [ -f "$STATE_DIR/night_whitelist.txt" ]; then
echo "Night whitelist: $(wc -l < "$STATE_DIR/night_whitelist.txt" | tr -d ' ') apps allowed" echo "Night whitelist: $(wc -l <"$STATE_DIR/night_whitelist.txt" | tr -d ' ') apps allowed"
fi fi
} }
@ -878,7 +893,8 @@ cmd_curfew_log() { tail -n "${1:-50}" "$CURFEW_ENFORCER_LOG" 2>/dev/null || echo
# Test hook: force curfew ACTIVE regardless of clock/location so the whole # Test hook: force curfew ACTIVE regardless of clock/location so the whole
# stack can be validated during the day. Daemon re-checks within one tick. # stack can be validated during the day. Daemon re-checks within one tick.
cmd_curfew_test_on() { cmd_curfew_test_on() {
touch "$CURFEW_FORCE_FILE"; chmod 666 "$CURFEW_FORCE_FILE" 2>/dev/null || true touch "$CURFEW_FORCE_FILE"
chmod 666 "$CURFEW_FORCE_FILE" 2>/dev/null || true
rm -f "$CURFEW_OVERRIDE_FILE" rm -f "$CURFEW_OVERRIDE_FILE"
touch "$RECHECK_TRIGGER" 2>/dev/null || true touch "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Curfew FORCED ON. App sweep + enforcer will engage within a few seconds." echo "Curfew FORCED ON. App sweep + enforcer will engage within a few seconds."
@ -895,7 +911,8 @@ cmd_curfew_test_off() {
# on-demand demo. The companion app's Start/Stop demo button drives the same # on-demand demo. The companion app's Start/Stop demo button drives the same
# force file. Easy off: curfew-demo-off (or tap "Stop demo curfew"). # force file. Easy off: curfew-demo-off (or tap "Stop demo curfew").
cmd_curfew_demo_on() { cmd_curfew_demo_on() {
touch "$CURFEW_FORCE_FILE"; chmod 666 "$CURFEW_FORCE_FILE" 2>/dev/null || true touch "$CURFEW_FORCE_FILE"
chmod 666 "$CURFEW_FORCE_FILE" 2>/dev/null || true
rm -f "$CURFEW_OVERRIDE_FILE" rm -f "$CURFEW_OVERRIDE_FILE"
touch "$RECHECK_TRIGGER" 2>/dev/null || true touch "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Demo curfew STARTED - full curfew engaged now (apps, grayscale, DND, net)." echo "Demo curfew STARTED - full curfew engaged now (apps, grayscale, DND, net)."
@ -912,7 +929,8 @@ cmd_curfew_demo_off() {
# until you re-arm. Reachable on-device only via ADB (this command) unless a # until you re-arm. Reachable on-device only via ADB (this command) unless a
# root file-manager/terminal has been added to NIGHT_WHITELIST. # root file-manager/terminal has been added to NIGHT_WHITELIST.
cmd_curfew_off() { cmd_curfew_off() {
touch "$CURFEW_OVERRIDE_FILE"; chmod 666 "$CURFEW_OVERRIDE_FILE" 2>/dev/null || true touch "$CURFEW_OVERRIDE_FILE"
chmod 666 "$CURFEW_OVERRIDE_FILE" 2>/dev/null || true
touch "$RECHECK_TRIGGER" 2>/dev/null || true touch "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Curfew SUSPENDED (override set: $CURFEW_OVERRIDE_FILE). Re-arm with: curfew-on" echo "Curfew SUSPENDED (override set: $CURFEW_OVERRIDE_FILE). Re-arm with: curfew-on"
} }
@ -953,8 +971,8 @@ cmd_tether_status() {
fi fi
[ -e "$TETHER_FORCE_FILE" ] && echo "Forced ON: YES (test active)" [ -e "$TETHER_FORCE_FILE" ] && echo "Forced ON: YES (test active)"
[ -e "$TETHER_OVERRIDE_FILE" ] && echo "Override: YES (block SUSPENDED)" [ -e "$TETHER_OVERRIDE_FILE" ] && echo "Override: YES (block SUSPENDED)"
[ -e "$TETHER_ENFORCER_STATE" ] && echo "Applied now: YES (offload off + FORWARD reject)" \ [ -e "$TETHER_ENFORCER_STATE" ] && echo "Applied now: YES (offload off + FORWARD reject)" ||
|| echo "Applied now: no" echo "Applied now: no"
local off local off
off="$(settings get global "$TETHER_OFFLOAD_KEY" 2>/dev/null)" off="$(settings get global "$TETHER_OFFLOAD_KEY" 2>/dev/null)"
echo "tether_offload_disabled: ${off:-<unset>}" echo "tether_offload_disabled: ${off:-<unset>}"
@ -1008,7 +1026,8 @@ cmd_tether_log() { tail -n "${1:-50}" "$TETHER_LOG" 2>/dev/null || echo "No teth
# Test hook: force the block ACTIVE regardless of location so the full stack can # Test hook: force the block ACTIVE regardless of location so the full stack can
# be validated during the day with a real second phone on the hotspot. # be validated during the day with a real second phone on the hotspot.
cmd_tether_test_on() { cmd_tether_test_on() {
touch "$TETHER_FORCE_FILE"; chmod 666 "$TETHER_FORCE_FILE" 2>/dev/null || true touch "$TETHER_FORCE_FILE"
chmod 666 "$TETHER_FORCE_FILE" 2>/dev/null || true
rm -f "$TETHER_OVERRIDE_FILE" rm -f "$TETHER_OVERRIDE_FILE"
echo "Tether block FORCED ON. Enforcer engages within ${TETHER_CHECK_INTERVAL}s." echo "Tether block FORCED ON. Enforcer engages within ${TETHER_CHECK_INTERVAL}s."
echo "Validate: second phone on the hotspot loses internet; this phone stays online." echo "Validate: second phone on the hotspot loses internet; this phone stays online."
@ -1020,49 +1039,53 @@ cmd_tether_test_off() {
} }
case "$1" in case "$1" in
start) cmd_start ;; start) cmd_start ;;
stop) cmd_stop ;; stop) cmd_stop ;;
status) cmd_status ;; status) cmd_status ;;
enable) cmd_enable ;; enable) cmd_enable ;;
disable) cmd_disable ;; disable) cmd_disable ;;
log) cmd_log "${2:-50}" ;; log) cmd_log "${2:-50}" ;;
list-apps) cmd_list_apps ;; list-apps) cmd_list_apps ;;
whitelist) cmd_whitelist ;; whitelist) cmd_whitelist ;;
restart) cmd_stop; sleep 2; cmd_start ;; restart)
hosts-status) cmd_hosts_status ;; cmd_stop
hosts-start) cmd_hosts_start ;; sleep 2
hosts-stop) cmd_hosts_stop ;; cmd_start
hosts-log) cmd_hosts_log "${2:-50}" ;; ;;
dns-status) cmd_dns_status ;; hosts-status) cmd_hosts_status ;;
dns-start) cmd_dns_start ;; hosts-start) cmd_hosts_start ;;
dns-stop) cmd_dns_stop ;; hosts-stop) cmd_hosts_stop ;;
dns-log) cmd_dns_log "${2:-50}" ;; hosts-log) cmd_hosts_log "${2:-50}" ;;
launcher-status) cmd_launcher_status ;; dns-status) cmd_dns_status ;;
launcher-start) cmd_launcher_start ;; dns-start) cmd_dns_start ;;
launcher-stop) cmd_launcher_stop ;; dns-stop) cmd_dns_stop ;;
launcher-log) cmd_launcher_log "${2:-50}" ;; dns-log) cmd_dns_log "${2:-50}" ;;
launcher-snapshot) cmd_launcher_snapshot ;; launcher-status) cmd_launcher_status ;;
workout-status) cmd_workout_status ;; launcher-start) cmd_launcher_start ;;
workout-start) cmd_workout_start ;; launcher-stop) cmd_launcher_stop ;;
workout-stop) cmd_workout_stop ;; launcher-log) cmd_launcher_log "${2:-50}" ;;
workout-log) cmd_workout_log "${2:-50}" ;; launcher-snapshot) cmd_launcher_snapshot ;;
recheck) cmd_recheck ;; workout-status) cmd_workout_status ;;
notif-status) cmd_notif_status ;; workout-start) cmd_workout_start ;;
curfew-status) cmd_curfew_status ;; workout-stop) cmd_workout_stop ;;
curfew-start) cmd_curfew_start ;; workout-log) cmd_workout_log "${2:-50}" ;;
curfew-stop) cmd_curfew_stop ;; recheck) cmd_recheck ;;
curfew-log) cmd_curfew_log "${2:-50}" ;; notif-status) cmd_notif_status ;;
curfew-test-on) cmd_curfew_test_on ;; curfew-status) cmd_curfew_status ;;
curfew-test-off) cmd_curfew_test_off ;; curfew-start) cmd_curfew_start ;;
curfew-demo-on) cmd_curfew_demo_on ;; curfew-stop) cmd_curfew_stop ;;
curfew-demo-off) cmd_curfew_demo_off ;; curfew-log) cmd_curfew_log "${2:-50}" ;;
curfew-off) cmd_curfew_off ;; curfew-test-on) cmd_curfew_test_on ;;
curfew-on) cmd_curfew_on ;; curfew-test-off) cmd_curfew_test_off ;;
tether-status) cmd_tether_status ;; curfew-demo-on) cmd_curfew_demo_on ;;
tether-start) cmd_tether_start ;; curfew-demo-off) cmd_curfew_demo_off ;;
tether-stop) cmd_tether_stop ;; curfew-off) cmd_curfew_off ;;
tether-log) cmd_tether_log "${2:-50}" ;; curfew-on) cmd_curfew_on ;;
tether-test-on) cmd_tether_test_on ;; tether-status) cmd_tether_status ;;
tether-test-off) cmd_tether_test_off ;; tether-start) cmd_tether_start ;;
*) usage ;; tether-stop) cmd_tether_stop ;;
tether-log) cmd_tether_log "${2:-50}" ;;
tether-test-on) cmd_tether_test_on ;;
tether-test-off) cmd_tether_test_off ;;
*) usage ;;
esac esac

View File

@ -20,7 +20,7 @@ acquire_lock() {
if kill -0 "$old_pid" 2>/dev/null; then if kill -0 "$old_pid" 2>/dev/null; then
# Verify the PID is actually a focus_daemon, not a reused PID # Verify the PID is actually a focus_daemon, not a reused PID
local cmdline local cmdline
cmdline="$(cat /proc/$old_pid/cmdline 2>/dev/null | tr '\0' ' ')" cmdline="$(tr '\0' ' ' <"/proc/$old_pid/cmdline" 2>/dev/null)"
if echo "$cmdline" | grep -q "focus_daemon"; then if echo "$cmdline" | grep -q "focus_daemon"; then
echo "Daemon already running (PID $old_pid), exiting." echo "Daemon already running (PID $old_pid), exiting."
exit 0 exit 0
@ -29,22 +29,22 @@ acquire_lock() {
# Stale or reused pidfile # Stale or reused pidfile
rm -f "$PIDFILE" rm -f "$PIDFILE"
fi fi
echo $$ > "$PIDFILE" echo $$ >"$PIDFILE"
} }
# ---- Logging ---- # ---- Logging ----
log() { log() {
local ts local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')" ts="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$ts] $1" >> "$LOG_FILE" echo "[$ts] $1" >>"$LOG_FILE"
} }
rotate_log() { rotate_log() {
local lines local lines
lines="$(wc -l < "$LOG_FILE" 2>/dev/null || echo 0)" lines="$(wc -l <"$LOG_FILE" 2>/dev/null || echo 0)"
if [ "$lines" -gt "$LOG_MAX_LINES" ]; then if [ "$lines" -gt "$LOG_MAX_LINES" ]; then
local tmp="$LOG_FILE.tmp" local tmp="$LOG_FILE.tmp"
tail -n "$LOG_MAX_LINES" "$LOG_FILE" > "$tmp" tail -n "$LOG_MAX_LINES" "$LOG_FILE" >"$tmp"
mv "$tmp" "$LOG_FILE" mv "$tmp" "$LOG_FILE"
fi fi
} }
@ -52,15 +52,15 @@ rotate_log() {
# ---- Build helper files for fast package checks ---- # ---- Build helper files for fast package checks ----
build_whitelist_file() { build_whitelist_file() {
echo "$WHITELIST" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \ echo "$WHITELIST" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' |
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$STATE_DIR/whitelist.txt" sed 's/^[[:space:]]*//;s/[[:space:]]*$//' >"$STATE_DIR/whitelist.txt"
# Sanity check: the WHITELIST string in config.sh is fragile - any # Sanity check: the WHITELIST string in config.sh is fragile - any
# literal double-quote inside a comment will close the heredoc and # literal double-quote inside a comment will close the heredoc and
# silently truncate the variable. Log the parsed line count so any # silently truncate the variable. Log the parsed line count so any
# future regression is visible in the log, and warn loudly if it # future regression is visible in the log, and warn loudly if it
# falls below a known floor (we always have ~70+ entries). # falls below a known floor (we always have ~70+ entries).
local n local n
n=$(wc -l < "$STATE_DIR/whitelist.txt" 2>/dev/null | tr -d ' ') n=$(wc -l <"$STATE_DIR/whitelist.txt" 2>/dev/null | tr -d ' ')
log "Whitelist parsed: $n entries" log "Whitelist parsed: $n entries"
if [ "${n:-0}" -lt 30 ]; then if [ "${n:-0}" -lt 30 ]; then
log "WARN: whitelist suspiciously small ($n lines) - check config.sh for stray quotes inside WHITELIST string" log "WARN: whitelist suspiciously small ($n lines) - check config.sh for stray quotes inside WHITELIST string"
@ -70,10 +70,10 @@ build_whitelist_file() {
build_night_whitelist_file() { build_night_whitelist_file() {
# Strict allow-list used while the night curfew is active (see config.sh # Strict allow-list used while the night curfew is active (see config.sh
# NIGHT_WHITELIST and is_curfew_now()). Parsed exactly like the day list. # NIGHT_WHITELIST and is_curfew_now()). Parsed exactly like the day list.
echo "$NIGHT_WHITELIST" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \ echo "$NIGHT_WHITELIST" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' |
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$STATE_DIR/night_whitelist.txt" sed 's/^[[:space:]]*//;s/[[:space:]]*$//' >"$STATE_DIR/night_whitelist.txt"
local n local n
n=$(wc -l < "$STATE_DIR/night_whitelist.txt" 2>/dev/null | tr -d ' ') n=$(wc -l <"$STATE_DIR/night_whitelist.txt" 2>/dev/null | tr -d ' ')
log "Night-curfew whitelist parsed: $n entries" log "Night-curfew whitelist parsed: $n entries"
if [ "${n:-0}" -lt 10 ]; then if [ "${n:-0}" -lt 10 ]; then
log "WARN: night whitelist suspiciously small ($n lines) - check config.sh for stray quotes inside NIGHT_WHITELIST string" log "WARN: night whitelist suspiciously small ($n lines) - check config.sh for stray quotes inside NIGHT_WHITELIST string"
@ -81,23 +81,23 @@ build_night_whitelist_file() {
} }
build_sysprotect_file() { build_sysprotect_file() {
echo "$SYSTEM_NEVER_DISABLE" | grep -v '^[[:space:]]*$' \ echo "$SYSTEM_NEVER_DISABLE" | grep -v '^[[:space:]]*$' |
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$STATE_DIR/sysprotect.txt" sed 's/^[[:space:]]*//;s/[[:space:]]*$//' >"$STATE_DIR/sysprotect.txt"
} }
# $BLOCKED_SYSTEM_APPS is a static config value (never changes without a # $BLOCKED_SYSTEM_APPS is a static config value (never changes without a
# daemon restart), so - like build_sysprotect_file above - this only needs # daemon restart), so - like build_sysprotect_file above - this only needs
# to run once at startup, not every enable_focus_mode() sweep. # to run once at startup, not every enable_focus_mode() sweep.
build_blocked_sys_file() { build_blocked_sys_file() {
echo "$BLOCKED_SYSTEM_APPS" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \ echo "$BLOCKED_SYSTEM_APPS" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' |
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$STATE_DIR/blocked_sys.txt" sed 's/^[[:space:]]*//;s/[[:space:]]*$//' >"$STATE_DIR/blocked_sys.txt"
} }
reconcile_disabled_apps() { reconcile_disabled_apps() {
[ -f "$DISABLED_APPS_FILE" ] || return [ -f "$DISABLED_APPS_FILE" ] || return
local tmp_disabled="$STATE_DIR/disabled_by_focus.tmp" local tmp_disabled="$STATE_DIR/disabled_by_focus.tmp"
: > "$tmp_disabled" : >"$tmp_disabled"
while IFS= read -r pkg; do while IFS= read -r pkg; do
[ -z "$pkg" ] && continue [ -z "$pkg" ] && continue
@ -109,8 +109,8 @@ reconcile_disabled_apps() {
continue continue
fi fi
echo "$pkg" >> "$tmp_disabled" echo "$pkg" >>"$tmp_disabled"
done < "$DISABLED_APPS_FILE" done <"$DISABLED_APPS_FILE"
mv "$tmp_disabled" "$DISABLED_APPS_FILE" mv "$tmp_disabled" "$DISABLED_APPS_FILE"
} }
@ -165,9 +165,9 @@ init() {
# ---- Location ---- # ---- Location ----
get_location() { get_location() {
dumpsys location 2>/dev/null \ dumpsys location 2>/dev/null |
| grep -oE '[-]?[0-9]{1,3}\.[0-9]{4,},[-]?[0-9]{1,3}\.[0-9]{4,}' \ grep -oE '[-]?[0-9]{1,3}\.[0-9]{4,},[-]?[0-9]{1,3}\.[0-9]{4,}' |
| head -1 head -1
} }
# ---- Distance Calculation (Haversine via awk) ---- # ---- Distance Calculation (Haversine via awk) ----
@ -207,9 +207,11 @@ is_curfew_now() {
local now start end local now start end
now="$(date +%H%M 2>/dev/null)" now="$(date +%H%M 2>/dev/null)"
case "$now" in case "$now" in
''|*[!0-9]*) return 1 ;; '' | *[!0-9]*) return 1 ;;
esac esac
now="$(_dec "$now")"; start="$(_dec "$NIGHT_CURFEW_START")"; end="$(_dec "$NIGHT_CURFEW_END")" now="$(_dec "$now")"
start="$(_dec "$NIGHT_CURFEW_START")"
end="$(_dec "$NIGHT_CURFEW_END")"
if [ "$start" -le "$end" ]; then if [ "$start" -le "$end" ]; then
[ "$now" -ge "$start" ] && [ "$now" -lt "$end" ] [ "$now" -ge "$start" ] && [ "$now" -lt "$end" ]
else else
@ -263,7 +265,7 @@ is_allowed() {
case "$pkg" in case "$pkg" in
"$prefix"*) return 0 ;; "$prefix"*) return 0 ;;
esac esac
done < "$STATE_DIR/sysprotect.txt" done <"$STATE_DIR/sysprotect.txt"
# Hard-stop guard: refuse to disable any package that is the current # Hard-stop guard: refuse to disable any package that is the current
# default handler for a critical role (Dialer / SMS / Home / Contacts). # default handler for a critical role (Dialer / SMS / Home / Contacts).
# Without this, a misconfigured WHITELIST can disable the default Phone # Without this, a misconfigured WHITELIST can disable the default Phone
@ -275,8 +277,8 @@ is_allowed() {
is_default_handler "$pkg" && return 0 is_default_handler "$pkg" && return 0
# The default browser is guarded only OUTSIDE curfew. At night the whole # The default browser is guarded only OUTSIDE curfew. At night the whole
# point is to disable browsers, so this guard must not re-allow it. # point is to disable browsers, so this guard must not re-allow it.
if ! curfew_active \ if ! curfew_active &&
&& grep -qxF "$pkg" "$STATE_DIR/default_browser.txt" 2>/dev/null; then grep -qxF "$pkg" "$STATE_DIR/default_browser.txt" 2>/dev/null; then
return 0 return 0
fi fi
return 1 return 1
@ -288,27 +290,27 @@ is_allowed() {
refresh_default_handlers() { refresh_default_handlers() {
local f="$STATE_DIR/default_handlers.txt" local f="$STATE_DIR/default_handlers.txt"
local tmp="$f.tmp" local tmp="$f.tmp"
: > "$tmp" : >"$tmp"
# Default Home (launcher). resolve-activity prints "Activity Resolver Table:" # Default Home (launcher). resolve-activity prints "Activity Resolver Table:"
# on line 1 and "<pkg>/<.Activity>" on line 2 in --brief mode. # on line 1 and "<pkg>/<.Activity>" on line 2 in --brief mode.
cmd package resolve-activity --brief \ cmd package resolve-activity --brief \
-c android.intent.category.HOME -a android.intent.action.MAIN 2>/dev/null \ -c android.intent.category.HOME -a android.intent.action.MAIN 2>/dev/null |
| awk -F/ 'NR==2 && $1 != "" {print $1}' >> "$tmp" awk -F/ 'NR==2 && $1 != "" {print $1}' >>"$tmp"
# Default Dialer # Default Dialer
local dialer local dialer
dialer="$(cmd telecom get-default-dialer 2>/dev/null | tr -d '[:space:]')" dialer="$(cmd telecom get-default-dialer 2>/dev/null | tr -d '[:space:]')"
[ -n "$dialer" ] && echo "$dialer" >> "$tmp" [ -n "$dialer" ] && echo "$dialer" >>"$tmp"
# Default SMS handler (settings provider key) # Default SMS handler (settings provider key)
local sms local sms
sms="$(settings get secure sms_default_application 2>/dev/null | tr -d '[:space:]')" sms="$(settings get secure sms_default_application 2>/dev/null | tr -d '[:space:]')"
[ -n "$sms" ] && [ "$sms" != "null" ] && echo "$sms" >> "$tmp" [ -n "$sms" ] && [ "$sms" != "null" ] && echo "$sms" >>"$tmp"
# Default input method (active keyboard). Disabling the active IME with # Default input method (active keyboard). Disabling the active IME with
# pm disable-user PERSISTS across reboot; a 1am reboot would then leave no # pm disable-user PERSISTS across reboot; a 1am reboot would then leave no
# keyboard to type any recovery command. Protect it day and night so the # keyboard to type any recovery command. Protect it day and night so the
# curfew can never lock you out of typing. # curfew can never lock you out of typing.
local ime local ime
ime="$(settings get secure default_input_method 2>/dev/null | cut -d/ -f1)" ime="$(settings get secure default_input_method 2>/dev/null | cut -d/ -f1)"
[ -n "$ime" ] && [ "$ime" != "null" ] && echo "$ime" >> "$tmp" [ -n "$ime" ] && [ "$ime" != "null" ] && echo "$ime" >>"$tmp"
sort -u "$tmp" -o "$f" sort -u "$tmp" -o "$f"
rm -f "$tmp" rm -f "$tmp"
@ -317,9 +319,9 @@ refresh_default_handlers() {
# disable browsers, so the default-handler guard must not resurrect them. # disable browsers, so the default-handler guard must not resurrect them.
local bf="$STATE_DIR/default_browser.txt" local bf="$STATE_DIR/default_browser.txt"
cmd package resolve-activity --brief \ cmd package resolve-activity --brief \
-a android.intent.action.VIEW -d http://example.com 2>/dev/null \ -a android.intent.action.VIEW -d http://example.com 2>/dev/null |
| awk -F/ 'NR==2 && $1 != "" {print $1}' > "$bf.tmp" 2>/dev/null awk -F/ 'NR==2 && $1 != "" {print $1}' >"$bf.tmp" 2>/dev/null
mv "$bf.tmp" "$bf" 2>/dev/null || : > "$bf" mv "$bf.tmp" "$bf" 2>/dev/null || : >"$bf"
} }
is_default_handler() { is_default_handler() {
@ -334,7 +336,7 @@ enable_focus_mode() {
if [ "$CURRENT_MODE" != "focus" ]; then if [ "$CURRENT_MODE" != "focus" ]; then
first_entry=1 first_entry=1
log "ENABLING focus mode - restricting non-whitelisted apps" log "ENABLING focus mode - restricting non-whitelisted apps"
: > "$DISABLED_APPS_FILE" : >"$DISABLED_APPS_FILE"
fi fi
# Refresh default-handler list every tick. The user may switch dialer / # Refresh default-handler list every tick. The user may switch dialer /
@ -350,17 +352,17 @@ enable_focus_mode() {
# Play Store or `pm enable` in a terminal) since the last tick. # Play Store or `pm enable` in a terminal) since the last tick.
# -e = enabled only, so we skip apps that are already disabled. # -e = enabled only, so we skip apps that are already disabled.
local tmp_pkgs="$STATE_DIR/pkg_list.txt" local tmp_pkgs="$STATE_DIR/pkg_list.txt"
pm list packages -3 -e 2>/dev/null | sed 's/^package://' > "$tmp_pkgs" pm list packages -3 -e 2>/dev/null | sed 's/^package://' >"$tmp_pkgs"
local newly_disabled=0 local newly_disabled=0
while IFS= read -r pkg; do while IFS= read -r pkg; do
[ -z "$pkg" ] && continue [ -z "$pkg" ] && continue
is_allowed "$pkg" && continue is_allowed "$pkg" && continue
if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then
grep -qxF "$pkg" "$DISABLED_APPS_FILE" 2>/dev/null \ grep -qxF "$pkg" "$DISABLED_APPS_FILE" 2>/dev/null ||
|| echo "$pkg" >> "$DISABLED_APPS_FILE" echo "$pkg" >>"$DISABLED_APPS_FILE"
newly_disabled=$((newly_disabled + 1)) newly_disabled=$((newly_disabled + 1))
fi fi
done < "$tmp_pkgs" done <"$tmp_pkgs"
rm -f "$tmp_pkgs" rm -f "$tmp_pkgs"
# Disable-for-user-0 any blocked system apps (Play Store, browsers, # Disable-for-user-0 any blocked system apps (Play Store, browsers,
@ -376,18 +378,18 @@ enable_focus_mode() {
while IFS= read -r pkg; do while IFS= read -r pkg; do
[ -z "$pkg" ] && continue [ -z "$pkg" ] && continue
if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then
grep -qxF "$pkg" "$DISABLED_APPS_FILE" 2>/dev/null \ grep -qxF "$pkg" "$DISABLED_APPS_FILE" 2>/dev/null ||
|| echo "$pkg" >> "$DISABLED_APPS_FILE" echo "$pkg" >>"$DISABLED_APPS_FILE"
newly_disabled=$((newly_disabled + 1)) newly_disabled=$((newly_disabled + 1))
fi fi
done < "$blocked_sys" done <"$blocked_sys"
CURRENT_MODE="focus" CURRENT_MODE="focus"
echo "focus" > "$MODE_FILE" echo "focus" >"$MODE_FILE"
if [ "$first_entry" -eq 1 ]; then if [ "$first_entry" -eq 1 ]; then
local count local count
count=$(wc -l < "$DISABLED_APPS_FILE" 2>/dev/null || echo 0) count=$(wc -l <"$DISABLED_APPS_FILE" 2>/dev/null || echo 0)
log "Focus mode enabled - disabled $count apps" log "Focus mode enabled - disabled $count apps"
elif [ "$newly_disabled" -gt 0 ]; then elif [ "$newly_disabled" -gt 0 ]; then
log "Focus mode re-sweep: re-disabled $newly_disabled apps (re-enabled by user?)" log "Focus mode re-sweep: re-disabled $newly_disabled apps (re-enabled by user?)"
@ -408,12 +410,12 @@ disable_focus_mode() {
while IFS= read -r pkg; do while IFS= read -r pkg; do
[ -z "$pkg" ] && continue [ -z "$pkg" ] && continue
pm enable "$pkg" >/dev/null 2>&1 && count=$((count + 1)) pm enable "$pkg" >/dev/null 2>&1 && count=$((count + 1))
done < "$DISABLED_APPS_FILE" done <"$DISABLED_APPS_FILE"
: > "$DISABLED_APPS_FILE" : >"$DISABLED_APPS_FILE"
fi fi
CURRENT_MODE="normal" CURRENT_MODE="normal"
echo "normal" > "$MODE_FILE" echo "normal" >"$MODE_FILE"
log "Focus mode disabled - re-enabled $count apps" log "Focus mode disabled - re-enabled $count apps"
} }
@ -424,7 +426,7 @@ disable_focus_mode() {
write_status_snapshot() { write_status_snapshot() {
local mode="$1" lat="$2" lon="$3" dist="$4" thr="$5" local mode="$1" lat="$2" lon="$3" dist="$4" thr="$5"
local count iso ts cf ov frc local count iso ts cf ov frc
count="$(wc -l < "$DISABLED_APPS_FILE" 2>/dev/null | tr -d ' ' || echo 0)" count="$(wc -l <"$DISABLED_APPS_FILE" 2>/dev/null | tr -d ' ' || echo 0)"
[ -z "$count" ] && count=0 [ -z "$count" ] && count=0
ts="$(date +%s)" ts="$(date +%s)"
iso="$(date '+%Y-%m-%d %H:%M:%S')" iso="$(date '+%Y-%m-%d %H:%M:%S')"
@ -453,7 +455,7 @@ write_status_snapshot() {
printf '"last_check_ts":%s,' "$ts" printf '"last_check_ts":%s,' "$ts"
printf '"last_check_iso":"%s"' "$iso" printf '"last_check_iso":"%s"' "$iso"
printf '}\n' printf '}\n'
} > "$tmp" 2>/dev/null || return 0 } >"$tmp" 2>/dev/null || return 0
mv "$tmp" "$STATUS_FILE" 2>/dev/null || true mv "$tmp" "$STATUS_FILE" 2>/dev/null || true
chmod 644 "$STATUS_FILE" 2>/dev/null || true chmod 644 "$STATUS_FILE" 2>/dev/null || true
} }
@ -516,7 +518,8 @@ main() {
disable_focus_mode disable_focus_mode
fi fi
curfew_state="day"; curfew_active && curfew_state="CURFEW" curfew_state="day"
curfew_active && curfew_state="CURFEW"
log "Location: $lat,$lon | Distance: ${distance}m | Threshold: ${threshold}m | Mode: $CURRENT_MODE | Curfew: $curfew_state" log "Location: $lat,$lon | Distance: ${distance}m | Threshold: ${threshold}m | Mode: $CURRENT_MODE | Curfew: $curfew_state"
write_status_snapshot "$CURRENT_MODE" "$lat" "$lon" "$distance" "$threshold" write_status_snapshot "$CURRENT_MODE" "$lat" "$lon" "$distance" "$threshold"
else else

View File

@ -11,13 +11,23 @@ cd "$SCRIPT_DIR"
SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}" SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}"
# Pick highest-numbered build-tools directory. # Pick highest-numbered build-tools directory.
BUILD_TOOLS_DIR="$(ls -1d "$SDK"/build-tools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/$::')" # A glob into an array rather than parsing ls: a build-tools path containing a
# space or newline would otherwise be split into garbage.
_bt_dirs=("$SDK"/build-tools/*/)
BUILD_TOOLS_DIR="$(printf '%s\n' "${_bt_dirs[@]}" | sort -V | tail -1 | sed 's:/$::')"
# Pick highest-numbered platform. # Pick highest-numbered platform.
PLATFORM_DIR="$(ls -1d "$SDK"/platforms/android-*/ 2>/dev/null | sort -V | tail -1 | sed 's:/$::')" _plat_dirs=("$SDK"/platforms/android-*/)
PLATFORM_DIR="$(printf '%s\n' "${_plat_dirs[@]}" | sort -V | tail -1 | sed 's:/$::')"
PLATFORM_JAR="$PLATFORM_DIR/android.jar" PLATFORM_JAR="$PLATFORM_DIR/android.jar"
[ -d "$BUILD_TOOLS_DIR" ] || { echo "ERROR: build-tools not found under $SDK" >&2; exit 1; } [ -d "$BUILD_TOOLS_DIR" ] || {
[ -f "$PLATFORM_JAR" ] || { echo "ERROR: android.jar not found at $PLATFORM_JAR" >&2; exit 1; } echo "ERROR: build-tools not found under $SDK" >&2
exit 1
}
[ -f "$PLATFORM_JAR" ] || {
echo "ERROR: android.jar not found at $PLATFORM_JAR" >&2
exit 1
}
AAPT2="$BUILD_TOOLS_DIR/aapt2" AAPT2="$BUILD_TOOLS_DIR/aapt2"
D8="$BUILD_TOOLS_DIR/d8" D8="$BUILD_TOOLS_DIR/d8"
@ -25,7 +35,10 @@ ZIPALIGN="$BUILD_TOOLS_DIR/zipalign"
APKSIGNER="$BUILD_TOOLS_DIR/apksigner" APKSIGNER="$BUILD_TOOLS_DIR/apksigner"
for tool in "$AAPT2" "$D8" "$ZIPALIGN" "$APKSIGNER"; do for tool in "$AAPT2" "$D8" "$ZIPALIGN" "$APKSIGNER"; do
[ -x "$tool" ] || { echo "ERROR: missing build tool: $tool" >&2; exit 1; } [ -x "$tool" ] || {
echo "ERROR: missing build tool: $tool" >&2
exit 1
}
done done
BUILD="$SCRIPT_DIR/build" BUILD="$SCRIPT_DIR/build"

View File

@ -25,6 +25,10 @@ mkdir -p "${XDG_STATE_HOME}"
source "${SCRIPT_DIR}/../adb_common.sh" source "${SCRIPT_DIR}/../adb_common.sh"
ADB_MOCK_MODEL=$'Pixel "7";$(rm -rf /)`danger`\nline2' ADB_MOCK_MODEL=$'Pixel "7";$(rm -rf /)`danger`\nline2'
# Single quotes are the whole point: this is an injection payload that must
# reach the code under test LITERALLY. Expanding $evil / `cmd` here would
# execute them in the test harness and test nothing.
# shellcheck disable=SC2016
ADB_MOCK_FINGERPRINT='google/pixel:14/UP1A.231005.007/$evil;`cmd`' ADB_MOCK_FINGERPRINT='google/pixel:14/UP1A.231005.007/$evil;`cmd`'
adb() { adb() {
@ -121,12 +125,17 @@ test_sanitize_device_string_removes_dangerous_chars() {
test_save_trusted_device_sanitizes_and_quotes() { test_save_trusted_device_sanitizes_and_quotes() {
local trusted_contents local trusted_contents
# Literal payload on purpose - see the note on ADB_MOCK_FINGERPRINT.
# shellcheck disable=SC2016
export ADB_SERIAL='SERIAL123$() ;' export ADB_SERIAL='SERIAL123$() ;'
adb_save_trusted_device adb_save_trusted_device
[[ -f "${TRUSTED_DEVICE_FILE}" ]] || return 1 [[ -f "${TRUSTED_DEVICE_FILE}" ]] || return 1
trusted_contents="$(cat "${TRUSTED_DEVICE_FILE}")" trusted_contents="$(cat "${TRUSTED_DEVICE_FILE}")"
# Asserting the sanitiser stripped these metacharacters, so the patterns
# must stay literal rather than expand.
# shellcheck disable=SC2016
[[ "${trusted_contents}" != *'$('* ]] [[ "${trusted_contents}" != *'$('* ]]
[[ "${trusted_contents}" != *';'* ]] [[ "${trusted_contents}" != *';'* ]]
[[ "${trusted_contents}" != *'`'* ]] [[ "${trusted_contents}" != *'`'* ]]

View File

@ -24,7 +24,7 @@ STATE="$RUN/state"
# --- fake config.sh in the daemon's own dir (it sources "$SCRIPT_DIR/config.sh") --- # --- fake config.sh in the daemon's own dir (it sources "$SCRIPT_DIR/config.sh") ---
cp "$SRC" "$RUN/app/tether_enforcer.sh" cp "$SRC" "$RUN/app/tether_enforcer.sh"
cat > "$RUN/app/config.sh" <<EOF cat >"$RUN/app/config.sh" <<EOF
export STATE_DIR="$STATE" export STATE_DIR="$STATE"
export MODE_FILE="$STATE/current_mode.txt" export MODE_FILE="$STATE/current_mode.txt"
export TETHER_ENFORCER_ENABLED=1 export TETHER_ENFORCER_ENABLED=1
@ -40,7 +40,7 @@ export TETHER_FORCE_FILE="$STATE/tether_force_on"
EOF EOF
# --- stubs --- # --- stubs ---
cat > "$RUN/bin/settings" <<EOF cat >"$RUN/bin/settings" <<EOF
#!/usr/bin/env bash #!/usr/bin/env bash
db="$RUN/settings/tether_offload_disabled" db="$RUN/settings/tether_offload_disabled"
echo "settings \$*" >> "$RUN/calls/settings.log" echo "settings \$*" >> "$RUN/calls/settings.log"
@ -52,7 +52,7 @@ esac
exit 0 exit 0
EOF EOF
# iptables/ip6tables: minimal chain-state model so chain_intact converges. # iptables/ip6tables: minimal chain-state model so chain_intact converges.
cat > "$RUN/bin/iptables" <<EOF cat >"$RUN/bin/iptables" <<EOF
#!/usr/bin/env bash #!/usr/bin/env bash
bin="\$(basename "\$0")"; s="$RUN/ipt/\$bin"; mkdir -p "\$s" bin="\$(basename "\$0")"; s="$RUN/ipt/\$bin"; mkdir -p "\$s"
[ "\$1" = "-w" ] && shift 2 [ "\$1" = "-w" ] && shift 2
@ -70,14 +70,21 @@ esac
exit \$? exit \$?
EOF EOF
cp "$RUN/bin/iptables" "$RUN/bin/ip6tables" cp "$RUN/bin/iptables" "$RUN/bin/ip6tables"
printf '#!/usr/bin/env bash\necho "cmd $*" >> "%s/calls/cmd.log"\nexit 0\n' "$RUN" > "$RUN/bin/cmd" printf '#!/usr/bin/env bash\necho "cmd $*" >> "%s/calls/cmd.log"\nexit 0\n' "$RUN" >"$RUN/bin/cmd"
printf '#!/usr/bin/env bash\necho 13\n' > "$RUN/bin/getprop" printf '#!/usr/bin/env bash\necho 13\n' >"$RUN/bin/getprop"
chmod +x "$RUN/bin/"* chmod +x "$RUN/bin/"*
export PATH="$RUN/bin:$PATH" export PATH="$RUN/bin:$PATH"
PASS=0; FAIL=0 PASS=0
ok() { PASS=$((PASS + 1)); printf ' OK: %s\n' "$1"; } FAIL=0
no() { FAIL=$((FAIL + 1)); printf ' FAIL: %s\n' "$1"; } ok() {
PASS=$((PASS + 1))
printf ' OK: %s\n' "$1"
}
no() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$1"
}
applied() { [ -f "$STATE/tether_applied" ]; } applied() { [ -f "$STATE/tether_applied" ]; }
offload() { cat "$RUN/settings/tether_offload_disabled" 2>/dev/null || echo "<unset>"; } offload() { cat "$RUN/settings/tether_offload_disabled" 2>/dev/null || echo "<unset>"; }
v4jump() { [ -f "$RUN/ipt/iptables/jump" ]; } v4jump() { [ -f "$RUN/ipt/iptables/jump" ]; }
@ -85,39 +92,44 @@ v6jump() { [ -f "$RUN/ipt/ip6tables/jump" ]; }
v4rules() { grep -c '^-A' "$RUN/ipt/iptables/rules" 2>/dev/null || echo 0; } v4rules() { grep -c '^-A' "$RUN/ipt/iptables/rules" 2>/dev/null || echo 0; }
softap() { grep -c 'stop-softap' "$RUN/calls/cmd.log" 2>/dev/null || echo 0; } softap() { grep -c 'stop-softap' "$RUN/calls/cmd.log" 2>/dev/null || echo 0; }
sh "$RUN/app/tether_enforcer.sh" & PID=$! sh "$RUN/app/tether_enforcer.sh" &
PID=$!
sleep 2 sleep 2
# [A] Away from home -> block OFF # [A] Away from home -> block OFF
applied && no "A: applied while away" || ok "A: not applied while away" if applied; then no "A: applied while away"; else ok "A: not applied while away"; fi
[ "$(offload)" = "<unset>" ] && ok "A: offload untouched away" || no "A: offload changed away" if [ "$(offload)" = "<unset>" ]; then ok "A: offload untouched away"; else no "A: offload changed away"; fi
v4jump && no "A: v4 jump while away" || ok "A: no v4 jump while away" if v4jump; then no "A: v4 jump while away"; else ok "A: no v4 jump while away"; fi
# [B] force-on -> block ENGAGES (all three levers) # [B] force-on -> block ENGAGES (all three levers)
touch "$STATE/tether_force_on"; sleep 3 touch "$STATE/tether_force_on"
applied && ok "B: applied on force" || no "B: not applied on force" sleep 3
[ "$(offload)" = "1" ] && ok "B: offload disabled" || no "B: offload=$(offload)" if applied; then ok "B: applied on force"; else no "B: not applied on force"; fi
v4jump && ok "B: v4 FORWARD jump pinned" || no "B: no v4 jump" if [ "$(offload)" = "1" ]; then ok "B: offload disabled"; else no "B: offload=$(offload)"; fi
v6jump && ok "B: v6 FORWARD jump pinned" || no "B: no v6 jump" if v4jump; then ok "B: v4 FORWARD jump pinned"; else no "B: no v4 jump"; fi
[ "$(v4rules)" = "1" ] && ok "B: single REJECT rule (no rebuild loop)" || no "B: v4 rules=$(v4rules)" if v6jump; then ok "B: v6 FORWARD jump pinned"; else no "B: no v6 jump"; fi
[ "$(softap)" -ge 1 ] && ok "B: stop-softap invoked" || no "B: softap not stopped" if [ "$(v4rules)" = "1" ]; then ok "B: single REJECT rule (no rebuild loop)"; else no "B: v4 rules=$(v4rules)"; fi
[ "$(cat "$STATE/tether_offload.snap" 2>/dev/null)" = "null" ] && ok "B: offload snapshot captured" || no "B: bad snapshot" if [ "$(softap)" -ge 1 ]; then ok "B: stop-softap invoked"; else no "B: softap not stopped"; fi
if [ "$(cat "$STATE/tether_offload.snap" 2>/dev/null)" = "null" ]; then ok "B: offload snapshot captured"; else no "B: bad snapshot"; fi
# [C] clear force -> block REVERTS # [C] clear force -> block REVERTS
rm -f "$STATE/tether_force_on"; sleep 3 rm -f "$STATE/tether_force_on"
applied && no "C: still applied" || ok "C: reverted on force-clear" sleep 3
[ "$(offload)" = "<unset>" ] && ok "C: offload restored" || no "C: offload=$(offload)" if applied; then no "C: still applied"; else ok "C: reverted on force-clear"; fi
v4jump && no "C: v4 jump remains" || ok "C: v4 chain torn down" if [ "$(offload)" = "<unset>" ]; then ok "C: offload restored"; else no "C: offload=$(offload)"; fi
if v4jump; then no "C: v4 jump remains"; else ok "C: v4 chain torn down"; fi
# [D] current_mode=focus -> ENGAGES via home gate # [D] current_mode=focus -> ENGAGES via home gate
echo focus > "$STATE/current_mode.txt"; sleep 3 echo focus >"$STATE/current_mode.txt"
applied && ok "D: applied at home (focus)" || no "D: not applied at home" sleep 3
v4jump && ok "D: v4 jump pinned at home" || no "D: no v4 jump at home" if applied; then ok "D: applied at home (focus)"; else no "D: not applied at home"; fi
if v4jump; then ok "D: v4 jump pinned at home"; else no "D: no v4 jump at home"; fi
# [E] override -> SUSPENDS even at home # [E] override -> SUSPENDS even at home
touch "$STATE/tether_override"; sleep 3 touch "$STATE/tether_override"
applied && no "E: applied despite override" || ok "E: suspended by override" sleep 3
v4jump && no "E: v4 jump despite override" || ok "E: torn down by override" if applied; then no "E: applied despite override"; else ok "E: suspended by override"; fi
if v4jump; then no "E: v4 jump despite override"; else ok "E: torn down by override"; fi
printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ] [ "$FAIL" -eq 0 ]