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:
- id: 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
types: [shell]
# shellcheck only understands sh/bash/dash/ksh; it cannot parse zsh
# syntax (e.g. extended globs like (#s)). zsh files are checked by the
# zsh-syntax hook below instead.
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)

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
# 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
# Exits: on -h/--help (exit 0) or unknown arg starting with - (exit 2)
# 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).
# 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() {
local arg="$1"
local usage_fn="${2:-usage}"
@ -137,7 +140,6 @@ handle_arg_help_or_unknown() {
return 1 # Not a flag, let caller handle it
;;
esac
return 0
}
# Initialize a setup script with common boilerplate
@ -186,7 +188,7 @@ is_focus_app_running() {
if command -v xdotool &>/dev/null && [[ ${#FOCUS_APPS_WINDOWS[@]} -gt 0 ]]; then
local regex wid
printf -v regex '%s|' "${FOCUS_APPS_WINDOWS[@]}"
regex="${regex%|}" # strip trailing |
regex="${regex%|}" # strip trailing |
while IFS= read -r wid; do
[[ -n $wid ]] || continue
echo "focus app"
@ -199,7 +201,7 @@ is_focus_app_running() {
for app in "${FOCUS_APPS_PROCESSES[@]}"; do
for comm in /proc/[0-9]*/comm; do
[[ -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
echo "$_proc_comm"
return 0
@ -450,7 +452,7 @@ get_second() {
# Get Unix timestamp from boot (uptime in seconds)
# Usage: boot_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"
}

View File

@ -18,12 +18,12 @@ DRY_RUN=0
STATUS_ONLY=0
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
RED=$'\033[0;31m'
GREEN=$'\033[0;32m'
YELLOW=$'\033[0;33m'
BLUE=$'\033[0;34m'
CYAN=$'\033[0;36m'
NC=$'\033[0m' # No Color
# Get script and config directories
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.
######################################################################
if [[ ${#MISSING_SCRIPTS[@]} -gt 0 ]]; then
printf "\n${RED}"
printf '\n%s' "$RED"
printf '=%.0s' {1..74}
printf "\n SELF-REPAIR IS BROKEN — %d repair script(s) MISSING\n" "${#MISSING_SCRIPTS[@]}"
printf '=%.0s' {1..74}
printf "${NC}\n"
printf '%s\n' "$NC"
for missing in "${MISSING_SCRIPTS[@]}"; do
err "$missing"
done
printf "${RED}The services above are NOT being repaired — this tool found the\n"
printf "problem and then could not run the fix. Correct these paths first.${NC}\n"
printf "${CYAN}Also logged at error priority: journalctl -p err -t check-and-enable-services${NC}\n\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.%s\n' "$NC"
printf '%sAlso logged at error priority: journalctl -p err -t check-and-enable-services%s\n\n' "$CYAN" "$NC"
exit 2
fi

View File

@ -12,19 +12,22 @@ set -euo pipefail
# Source common library for shared functions
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
if [[ -f "$SCRIPT_DIR/../../lib/common.sh" ]]; then
# shellcheck source=../../lib/common.sh
source "$SCRIPT_DIR/../../lib/common.sh"
# shellcheck source=../../lib/common.sh
source "$SCRIPT_DIR/../../lib/common.sh"
elif [[ -f "/usr/local/lib/common.sh" ]]; then
# shellcheck source=/usr/local/lib/common.sh
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"
else
echo "ERROR: common.sh library not found"
exit 1
echo "ERROR: common.sh library not found"
exit 1
fi
# Configuration
LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/music-parallelism"
mkdir -p "$LOG_DIR" 2> /dev/null || true
mkdir -p "$LOG_DIR" 2>/dev/null || true
export LOG_FILE="$LOG_DIR/music-parallelism.log"
CHECK_INTERVAL=15
FAST_CHECK_INTERVAL=5
@ -33,60 +36,60 @@ ENFORCEMENT_COOLDOWN=20
PROC_ROOT="${PROC_ROOT:-/proc}"
MUSIC_PROCESS_NAMES=(
"youtube-music"
"spotify"
"tidal"
"deezer"
"amazon music"
"youtube-music"
"spotify"
"tidal"
"deezer"
"amazon music"
)
# Override focus apps with extended list for this script
FOCUS_APPS_WINDOWS=(
# IDEs and code editors - match window titles
"Visual Studio Code"
"VSCodium"
"Cursor"
"IntelliJ IDEA"
"PyCharm"
"WebStorm"
"CLion"
"Rider"
"Sublime Text"
"Atom"
"Neovide"
# Gaming
"Steam"
# Creative apps
"Blender"
"Godot"
"Unity"
"Unreal Editor"
# IDEs and code editors - match window titles
"Visual Studio Code"
"VSCodium"
"Cursor"
"IntelliJ IDEA"
"PyCharm"
"WebStorm"
"CLion"
"Rider"
"Sublime Text"
"Atom"
"Neovide"
# Gaming
"Steam"
# Creative apps
"Blender"
"Godot"
"Unity"
"Unreal Editor"
)
# Music streaming services - browser tabs or electron apps
# These will be killed when focus apps are detected
MUSIC_SERVICES=(
# YouTube Music specific patterns (NOT regular YouTube)
"music.youtube.com"
"youtube-music" # Electron app
"YouTube Music" # Window title
# Spotify
"spotify"
"Spotify"
# Tidal
"tidal"
"TIDAL"
# Deezer
"deezer"
# Amazon Music
"Amazon Music"
"amazon music"
# Apple Music (web)
"music.apple.com"
# SoundCloud
"soundcloud.com"
# Pandora
"pandora.com"
# YouTube Music specific patterns (NOT regular YouTube)
"music.youtube.com"
"youtube-music" # Electron app
"YouTube Music" # Window title
# Spotify
"spotify"
"Spotify"
# Tidal
"tidal"
"TIDAL"
# Deezer
"deezer"
# Amazon Music
"Amazon Music"
"amazon music"
# Apple Music (web)
"music.apple.com"
# SoundCloud
"soundcloud.com"
# Pandora
"pandora.com"
)
readonly MUSIC_WINDOWS_PATTERN='YouTube Music|music\.youtube\.com|music\.apple\.com|soundcloud\.com|pandora\.com|deezer\.com|tidal\.com'
@ -97,313 +100,313 @@ MUSIC_FOUND_PROCESS=0
MUSIC_FOUND_WINDOW=0
wait_seconds() {
local timeout_s=$1
local start_ts end_ts elapsed_s remaining_s
local timeout_s=$1
local start_ts end_ts elapsed_s remaining_s
if [[ -n ${MUSIC_PARALLELISM_TEST_WAIT_LOG:-} ]]; then
printf '%s\n' "$timeout_s" >> "$MUSIC_PARALLELISM_TEST_WAIT_LOG"
if [[ ${MUSIC_PARALLELISM_TEST_EXIT_AFTER_WAIT:-0} -eq 1 ]]; then
exit 99
fi
return 0
fi
if [[ -n ${MUSIC_PARALLELISM_TEST_WAIT_LOG:-} ]]; then
printf '%s\n' "$timeout_s" >>"$MUSIC_PARALLELISM_TEST_WAIT_LOG"
if [[ ${MUSIC_PARALLELISM_TEST_EXIT_AFTER_WAIT:-0} -eq 1 ]]; then
exit 99
fi
return 0
fi
printf -v start_ts '%(%s)T' -1
IFS= read -r -t "$timeout_s" || true
printf -v end_ts '%(%s)T' -1
printf -v start_ts '%(%s)T' -1
IFS= read -r -t "$timeout_s" || true
printf -v end_ts '%(%s)T' -1
elapsed_s=$((end_ts - start_ts))
if (( elapsed_s < timeout_s )); then
remaining_s=$((timeout_s - elapsed_s))
sleep "$remaining_s"
fi
elapsed_s=$((end_ts - start_ts))
if ((elapsed_s < timeout_s)); then
remaining_s=$((timeout_s - elapsed_s))
sleep "$remaining_s"
fi
}
contains_music_process() {
local comm_file comm_lower token_lower
local comm_file comm_lower token_lower
for comm_file in "$PROC_ROOT"/[0-9]*/comm; do
[[ -r $comm_file ]] || continue
read -r comm_lower < "$comm_file" || continue
comm_lower=${comm_lower,,}
for comm_file in "$PROC_ROOT"/[0-9]*/comm; do
[[ -r $comm_file ]] || continue
read -r comm_lower <"$comm_file" || continue
comm_lower=${comm_lower,,}
for token_lower in "${MUSIC_PROCESS_NAMES[@]}"; do
if [[ $comm_lower == *"${token_lower,,}"* ]]; then
return 0
fi
done
done
for token_lower in "${MUSIC_PROCESS_NAMES[@]}"; do
if [[ $comm_lower == *"${token_lower,,}"* ]]; then
return 0
fi
done
done
return 1
return 1
}
# Check if any music service is running and return its details (OPTIMIZED: batch pgrep calls)
find_music_services() {
local found_services=()
MUSIC_FOUND_PROCESS=0
MUSIC_FOUND_WINDOW=0
local found_services=()
MUSIC_FOUND_PROCESS=0
MUSIC_FOUND_WINDOW=0
# Check processes using /proc (fork-free)
if contains_music_process; then
MUSIC_FOUND_PROCESS=1
found_services+=("music process")
fi
# Check processes using /proc (fork-free)
if contains_music_process; then
MUSIC_FOUND_PROCESS=1
found_services+=("music process")
fi
# Check windows (use optimized is_focus_app_running logic: single xdotool regex call)
if command -v xdotool &> /dev/null && [[ ${#MUSIC_SERVICES[@]} -gt 0 ]]; then
if xdotool search --name "$MUSIC_WINDOWS_PATTERN" &> /dev/null 2>&1; then
MUSIC_FOUND_WINDOW=1
found_services+=("music service (window)")
fi
fi
# Check windows (use optimized is_focus_app_running logic: single xdotool regex call)
if command -v xdotool &>/dev/null && [[ ${#MUSIC_SERVICES[@]} -gt 0 ]]; then
if xdotool search --name "$MUSIC_WINDOWS_PATTERN" &>/dev/null 2>&1; then
MUSIC_FOUND_WINDOW=1
found_services+=("music service (window)")
fi
fi
if [[ ${#found_services[@]} -gt 0 ]]; then
printf '%s\n' "${found_services[@]}"
return 0
fi
return 1
if [[ ${#found_services[@]} -gt 0 ]]; then
printf '%s\n' "${found_services[@]}"
return 0
fi
return 1
}
# Kill music services
kill_music_services() {
local use_cached_detection="${1:-0}"
local killed=false
local window_pattern='YouTube Music|music\.youtube\.com|music\.apple\.com|soundcloud\.com|pandora\.com|deezer\.com|tidal\.com'
local should_check_windows=1
local should_check_processes=1
local use_cached_detection="${1:-0}"
local killed=false
local window_pattern='YouTube Music|music\.youtube\.com|music\.apple\.com|soundcloud\.com|pandora\.com|deezer\.com|tidal\.com'
local should_check_windows=1
local should_check_processes=1
if [[ $use_cached_detection -eq 1 ]]; then
should_check_windows=$MUSIC_FOUND_WINDOW
should_check_processes=$MUSIC_FOUND_PROCESS
fi
if [[ $use_cached_detection -eq 1 ]]; then
should_check_windows=$MUSIC_FOUND_WINDOW
should_check_processes=$MUSIC_FOUND_PROCESS
fi
# Close browser tabs for web-based music services via one xdotool search
if [[ $should_check_windows -eq 1 ]] && command -v xdotool &> /dev/null; then
local windows wid
windows=$(xdotool search --name "$window_pattern" 2> /dev/null || true)
for wid in $windows; do
[[ -n $wid ]] || continue
xdotool windowclose "$wid" 2> /dev/null || true
killed=true
done
fi
# Close browser tabs for web-based music services via one xdotool search
if [[ $should_check_windows -eq 1 ]] && command -v xdotool &>/dev/null; then
local windows wid
windows=$(xdotool search --name "$window_pattern" 2>/dev/null || true)
for wid in $windows; do
[[ -n $wid ]] || continue
xdotool windowclose "$wid" 2>/dev/null || true
killed=true
done
fi
# Kill app processes with /proc scan + builtin kill (fork-free in hot path)
if [[ $should_check_processes -eq 1 ]]; then
local comm_file pid comm_lower token_lower
for comm_file in "$PROC_ROOT"/[0-9]*/comm; do
[[ -r $comm_file ]] || continue
read -r comm_lower < "$comm_file" || continue
comm_lower=${comm_lower,,}
pid=${comm_file#"$PROC_ROOT"/}
pid=${pid%%/*}
# Kill app processes with /proc scan + builtin kill (fork-free in hot path)
if [[ $should_check_processes -eq 1 ]]; then
local comm_file pid comm_lower token_lower
for comm_file in "$PROC_ROOT"/[0-9]*/comm; do
[[ -r $comm_file ]] || continue
read -r comm_lower <"$comm_file" || continue
comm_lower=${comm_lower,,}
pid=${comm_file#"$PROC_ROOT"/}
pid=${pid%%/*}
for token_lower in "${MUSIC_PROCESS_NAMES[@]}"; do
if [[ $comm_lower == *"${token_lower,,}"* ]]; then
if [[ $PROC_ROOT != "/proc" ]]; then
# Test mode (fake proc tree): mark as killed without signaling host PIDs.
killed=true
elif kill -9 "$pid" 2> /dev/null; then
killed=true
fi
break
fi
done
done
fi
for token_lower in "${MUSIC_PROCESS_NAMES[@]}"; do
if [[ $comm_lower == *"${token_lower,,}"* ]]; then
if [[ $PROC_ROOT != "/proc" ]]; then
# Test mode (fake proc tree): mark as killed without signaling host PIDs.
killed=true
elif kill -9 "$pid" 2>/dev/null; then
killed=true
fi
break
fi
done
done
fi
if $killed; then
return 0
fi
return 1
if $killed; then
return 0
fi
return 1
}
# Send notification to user
notify_user() {
local focus_app="$1"
local message="Music stopped - focus mode active ($focus_app detected)"
local focus_app="$1"
local message="Music stopped - focus mode active ($focus_app detected)"
# Try to send desktop notification
if command -v notify-send &> /dev/null; then
notify-send -u normal -t 5000 "🎵 Music Parallelism" "$message" 2> /dev/null || true
fi
# Try to send desktop notification
if command -v notify-send &>/dev/null; then
notify-send -u normal -t 5000 "🎵 Music Parallelism" "$message" 2>/dev/null || true
fi
log_message "$message"
log_message "$message"
}
# Instant monitoring loop - uses polling at high frequency ONLY when focus app is detected
# When focus app active: checks every 0.5s. When idle: checks every 3s. Reduces fork overhead.
# OPTIMIZATION: Single batched pgrep call instead of multiple separate calls
instant_monitor_loop() {
local next_enforcement_ts=0
local current_ts=0
local focus_app=""
local sleep_interval="$IDLE_CHECK_INTERVAL"
local next_enforcement_ts=0
local current_ts=0
local focus_app=""
local sleep_interval="$IDLE_CHECK_INTERVAL"
log_message "=== Music Parallelism INSTANT Monitor Started ==="
log_message "Focus apps (windows): ${FOCUS_APPS_WINDOWS[*]}"
log_message "Focus apps (processes): ${FOCUS_APPS_PROCESSES[*]}"
log_message "Polling: ${FAST_CHECK_INTERVAL}s active, ${ACTIVE_NO_MUSIC_INTERVAL}s stable-focus, ${IDLE_CHECK_INTERVAL}s idle, ${ENFORCEMENT_COOLDOWN}s enforcement cooldown"
log_message "=== Music Parallelism INSTANT Monitor Started ==="
log_message "Focus apps (windows): ${FOCUS_APPS_WINDOWS[*]}"
log_message "Focus apps (processes): ${FOCUS_APPS_PROCESSES[*]}"
log_message "Polling: ${FAST_CHECK_INTERVAL}s active, ${ACTIVE_NO_MUSIC_INTERVAL}s stable-focus, ${IDLE_CHECK_INTERVAL}s idle, ${ENFORCEMENT_COOLDOWN}s enforcement cooldown"
while true; do
if focus_app=$(is_focus_app_running 2> /dev/null); then
current_ts=$(get_timestamp)
if (( current_ts >= next_enforcement_ts )); then
if find_music_services > /dev/null 2>&1; then
if kill_music_services 1; then
notify_user "$focus_app"
log_message "INSTANT KILL: Music services terminated"
sleep_interval="$ACTIVE_AFTER_KILL_INTERVAL"
fi
else
sleep_interval="$ACTIVE_NO_MUSIC_INTERVAL"
fi
next_enforcement_ts=$((current_ts + ENFORCEMENT_COOLDOWN))
else
sleep_interval="$ACTIVE_NO_MUSIC_INTERVAL"
fi
else
next_enforcement_ts=0
sleep_interval="$IDLE_CHECK_INTERVAL"
fi
while true; do
if focus_app=$(is_focus_app_running 2>/dev/null); then
current_ts=$(get_timestamp)
if ((current_ts >= next_enforcement_ts)); then
if find_music_services >/dev/null 2>&1; then
if kill_music_services 1; then
notify_user "$focus_app"
log_message "INSTANT KILL: Music services terminated"
sleep_interval="$ACTIVE_AFTER_KILL_INTERVAL"
fi
else
sleep_interval="$ACTIVE_NO_MUSIC_INTERVAL"
fi
next_enforcement_ts=$((current_ts + ENFORCEMENT_COOLDOWN))
else
sleep_interval="$ACTIVE_NO_MUSIC_INTERVAL"
fi
else
next_enforcement_ts=0
sleep_interval="$IDLE_CHECK_INTERVAL"
fi
wait_seconds "$sleep_interval"
done
wait_seconds "$sleep_interval"
done
}
# Main monitoring loop
monitor_loop() {
log_message "=== Music Parallelism Monitor Started ==="
log_message "Focus apps (windows): ${FOCUS_APPS_WINDOWS[*]}"
log_message "Focus apps (processes): ${FOCUS_APPS_PROCESSES[*]}"
log_message "Music services monitored: ${MUSIC_SERVICES[*]}"
log_message "Check interval: ${CHECK_INTERVAL}s"
log_message "=== Music Parallelism Monitor Started ==="
log_message "Focus apps (windows): ${FOCUS_APPS_WINDOWS[*]}"
log_message "Focus apps (processes): ${FOCUS_APPS_PROCESSES[*]}"
log_message "Music services monitored: ${MUSIC_SERVICES[*]}"
log_message "Check interval: ${CHECK_INTERVAL}s"
while true; do
# Check if a focus app is running
local focus_app
if focus_app=$(is_focus_app_running); then
# Focus app detected, check for music services
local music_services
if music_services=$(find_music_services); then
log_message "Conflict detected: Focus app '$focus_app' running with music services"
log_message "Active music services: $music_services"
while true; do
# Check if a focus app is running
local focus_app
if focus_app=$(is_focus_app_running); then
# Focus app detected, check for music services
local music_services
if music_services=$(find_music_services); then
log_message "Conflict detected: Focus app '$focus_app' running with music services"
log_message "Active music services: $music_services"
# Kill the music services
if kill_music_services 1; then
notify_user "$focus_app"
fi
fi
fi
# Kill the music services
if kill_music_services 1; then
notify_user "$focus_app"
fi
fi
fi
wait_seconds "$CHECK_INTERVAL"
done
wait_seconds "$CHECK_INTERVAL"
done
}
# Show status
show_status() {
echo "Music Parallelism Monitor Status"
echo "================================="
echo ""
echo "Music Parallelism Monitor Status"
echo "================================="
echo ""
echo "Focus Applications (window-based detection):"
local focus_running=false
echo "Focus Applications (window-based detection):"
local focus_running=false
# Check windows (OPTIMIZED: single xdotool call with combined regex)
if command -v xdotool &> /dev/null && [[ ${#FOCUS_APPS_WINDOWS[@]} -gt 0 ]]; then
local regex
printf -v regex '%s|' "${FOCUS_APPS_WINDOWS[@]}"
regex="${regex%|}" # strip trailing |
if xdotool search --name "$regex" &> /dev/null 2>&1; then
echo " ✓ Focus window detected"
focus_running=true
fi
fi
# Check windows (OPTIMIZED: single xdotool call with combined regex)
if command -v xdotool &>/dev/null && [[ ${#FOCUS_APPS_WINDOWS[@]} -gt 0 ]]; then
local regex
printf -v regex '%s|' "${FOCUS_APPS_WINDOWS[@]}"
regex="${regex%|}" # strip trailing |
if xdotool search --name "$regex" &>/dev/null 2>&1; then
echo " ✓ Focus window detected"
focus_running=true
fi
fi
# Check processes using shared /proc-based helper (fork-free)
if is_focus_app_running > /dev/null 2>&1; then
echo " ✓ Focus process running"
focus_running=true
fi
# Check processes using shared /proc-based helper (fork-free)
if is_focus_app_running >/dev/null 2>&1; then
echo " ✓ Focus process running"
focus_running=true
fi
if ! $focus_running; then
echo " (none detected)"
fi
if ! $focus_running; then
echo " (none detected)"
fi
echo ""
echo "Music Services:"
local music_running=false
if music_services=$(find_music_services 2> /dev/null); then
echo "$music_services" | while read -r svc; do
echo " ♪ $svc (RUNNING)"
done
music_running=true
fi
if ! $music_running; then
echo " (none detected)"
fi
echo ""
echo "Music Services:"
local music_running=false
if music_services=$(find_music_services 2>/dev/null); then
echo "$music_services" | while read -r svc; do
echo " ♪ $svc (RUNNING)"
done
music_running=true
fi
if ! $music_running; then
echo " (none detected)"
fi
echo ""
if $focus_running && $music_running; then
echo "⚠️ CONFLICT: Focus app and music running together!"
echo " Music would be killed in monitoring mode."
elif $focus_running; then
echo "✓ Focus mode active (no music playing)"
elif $music_running; then
echo "✓ Music playing (no focus app detected - this is fine)"
else
echo "✓ Idle (nothing detected)"
fi
echo ""
if $focus_running && $music_running; then
echo "⚠️ CONFLICT: Focus app and music running together!"
echo " Music would be killed in monitoring mode."
elif $focus_running; then
echo "✓ Focus mode active (no music playing)"
elif $music_running; then
echo "✓ Music playing (no focus app detected - this is fine)"
else
echo "✓ Idle (nothing detected)"
fi
}
# Show usage
show_usage() {
echo "Music Parallelism Prevention Script"
echo "===================================="
echo ""
echo "Usage: $0 [command]"
echo ""
echo "Commands:"
echo " monitor - Start monitoring (default, checks every ${CHECK_INTERVAL}s)"
echo " instant - Instant monitoring (${FAST_CHECK_INTERVAL}s active / ${IDLE_CHECK_INTERVAL}s idle)"
echo " status - Show current status of focus apps and music services"
echo " kill - Immediately kill all music services"
echo " help - Show this help message"
echo ""
echo "Description:"
echo " This script prevents multitasking between focus work and music."
echo " When a focus application (VS Code, Steam, etc.) is detected"
echo " alongside a music streaming service, the music is stopped."
echo ""
echo " Music is allowed when no focus apps are running."
echo ""
echo "Music Parallelism Prevention Script"
echo "===================================="
echo ""
echo "Usage: $0 [command]"
echo ""
echo "Commands:"
echo " monitor - Start monitoring (default, checks every ${CHECK_INTERVAL}s)"
echo " instant - Instant monitoring (${FAST_CHECK_INTERVAL}s active / ${IDLE_CHECK_INTERVAL}s idle)"
echo " status - Show current status of focus apps and music services"
echo " kill - Immediately kill all music services"
echo " help - Show this help message"
echo ""
echo "Description:"
echo " This script prevents multitasking between focus work and music."
echo " When a focus application (VS Code, Steam, etc.) is detected"
echo " alongside a music streaming service, the music is stopped."
echo ""
echo " Music is allowed when no focus apps are running."
echo ""
}
# Main
case "${1:-instant}" in
monitor | start | run)
monitor_loop
;;
instant | fast)
instant_monitor_loop
;;
status)
show_status
;;
kill)
log_message "Manual kill requested"
if kill_music_services; then
echo "Music services killed"
else
echo "No music services found to kill"
fi
;;
help | -h | --help)
show_usage
;;
*)
echo "Unknown command: $1"
show_usage
exit 1
;;
monitor | start | run)
monitor_loop
;;
instant | fast)
instant_monitor_loop
;;
status)
show_status
;;
kill)
log_message "Manual kill requested"
if kill_music_services; then
echo "Music services killed"
else
echo "No music services found to kill"
fi
;;
help | -h | --help)
show_usage
;;
*)
echo "Unknown command: $1"
show_usage
exit 1
;;
esac

View File

@ -5,58 +5,58 @@ set -euo pipefail
SCRIPT_DIR=${BASH_SOURCE[0]%/*}
[[ $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"
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() {
local proc_file proc_name
for proc_file in /proc/[0-9]*/comm; do
[[ -r $proc_file ]] || continue
read -r proc_name < "$proc_file" || continue
case $proc_name in
aw-qt | aw-server)
return 0
;;
esac
done
return 1
local proc_file proc_name
for proc_file in /proc/[0-9]*/comm; do
[[ -r $proc_file ]] || continue
read -r proc_name <"$proc_file" || continue
case $proc_name in
aw-qt | aw-server)
return 0
;;
esac
done
return 1
}
emit() {
local state
if ! check_installed; then
state='uninstalled'
elif check_running; then
state='on'
else
state='off'
fi
local state
if ! check_installed; then
state='uninstalled'
elif check_running; then
state='on'
else
state='off'
fi
if ! i3blocks_update_if_changed_key "activitywatch_state" "$state"; then
return 0
fi
if ! i3blocks_update_if_changed_key "activitywatch_state" "$state"; then
return 0
fi
if [[ $state == 'uninstalled' ]]; then
echo "AW uninstalled"
echo
echo "#FF0000"
elif [[ $state == 'on' ]]; then
echo "AW on"
echo
echo "#00FF00"
else
echo "AW off"
echo
echo "#FF0000"
fi
if [[ $state == 'uninstalled' ]]; then
echo "AW uninstalled"
echo
echo "#FF0000"
elif [[ $state == 'on' ]]; then
echo "AW on"
echo
echo "#00FF00"
else
echo "AW off"
echo
echo "#FF0000"
fi
}
is_persist_mode() {
[[ ${BLOCK_INTERVAL:-} == "persist" ]]
[[ ${BLOCK_INTERVAL:-} == "persist" ]]
}
HEARTBEAT_INTERVAL_S=60
@ -64,10 +64,10 @@ HEARTBEAT_INTERVAL_S=60
emit
if is_persist_mode; then
# Intentionally calm heartbeat in persist mode: process-table event streams can
# be extremely noisy and cause unnecessary churn.
while true; do
i3blocks_wait_seconds "$HEARTBEAT_INTERVAL_S"
emit
done
# Intentionally calm heartbeat in persist mode: process-table event streams can
# be extremely noisy and cause unnecessary churn.
while true; do
i3blocks_wait_seconds "$HEARTBEAT_INTERVAL_S"
emit
done
fi

View File

@ -5,55 +5,55 @@ set -euo pipefail
SCRIPT_DIR=${BASH_SOURCE[0]%/*}
[[ $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"
get_bluetooth_info() {
local info
info=$(bluetoothctl info 2> /dev/null) || info=''
printf '%s\n' "$info"
local info
info=$(bluetoothctl info 2>/dev/null) || info=''
printf '%s\n' "$info"
}
emit() {
local bluetooth_info connected device line state_key
bluetooth_info=$(get_bluetooth_info)
local bluetooth_info connected device line state_key
bluetooth_info=$(get_bluetooth_info)
connected='no'
device=''
while IFS= read -r line; do
case $line in
*'Connected: yes')
connected='yes'
;;
*'Alias: '*)
device=${line#*Alias: }
;;
esac
done <<< "$bluetooth_info"
connected='no'
device=''
while IFS= read -r line; do
case $line in
*'Connected: yes')
connected='yes'
;;
*'Alias: '*)
device=${line#*Alias: }
;;
esac
done <<<"$bluetooth_info"
state_key="$connected|$device"
if ! i3blocks_update_if_changed_key "bluetooth_state" "$state_key"; then
return 0
fi
state_key="$connected|$device"
if ! i3blocks_update_if_changed_key "bluetooth_state" "$state_key"; then
return 0
fi
if [[ $connected == yes && -n $device ]]; then
echo "$device"
echo
echo "#50FA7B"
else
echo " Disconnected"
fi
if [[ $connected == yes && -n $device ]]; then
echo "$device"
echo
echo "#50FA7B"
else
echo " Disconnected"
fi
}
is_persist_mode() {
[[ ${BLOCK_INTERVAL:-} == "persist" ]]
[[ ${BLOCK_INTERVAL:-} == "persist" ]]
}
emit_throttled() {
if ! i3blocks_should_emit_by_interval_key "bluetooth_emit" "$EMIT_MIN_INTERVAL_S"; then
return 0
fi
emit
if ! i3blocks_should_emit_by_interval_key "bluetooth_emit" "$EMIT_MIN_INTERVAL_S"; then
return 0
fi
emit
}
EMIT_MIN_INTERVAL_S=2
@ -61,12 +61,12 @@ EMIT_MIN_INTERVAL_S=2
emit
if is_persist_mode; then
# React to BlueZ D-Bus signals instead of polling.
if command -v dbus-monitor > /dev/null 2>&1; then
dbus-monitor --system "type='signal',sender='org.bluez'" 2> /dev/null |
while read -r line; do
[[ $line == *"PropertiesChanged"* ]] || continue
emit_throttled
done
fi
# React to BlueZ D-Bus signals instead of polling.
if command -v dbus-monitor >/dev/null 2>&1; then
dbus-monitor --system "type='signal',sender='org.bluez'" 2>/dev/null |
while read -r line; do
[[ $line == *"PropertiesChanged"* ]] || continue
emit_throttled
done
fi
fi

View File

@ -5,62 +5,62 @@ set -euo pipefail
SCRIPT_DIR=${BASH_SOURCE[0]%/*}
[[ $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"
find_ethernet_interface() {
local iface_path iface
for iface_path in /sys/class/net/*; do
iface=${iface_path##*/}
[[ $iface == lo ]] && continue
[[ -d ${iface_path}/wireless ]] && continue
[[ -r ${iface_path}/operstate ]] || continue
printf '%s\n' "$iface"
return 0
done
return 1
local iface_path iface
for iface_path in /sys/class/net/*; do
iface=${iface_path##*/}
[[ $iface == lo ]] && continue
[[ -d ${iface_path}/wireless ]] && continue
[[ -r ${iface_path}/operstate ]] || continue
printf '%s\n' "$iface"
return 0
done
return 1
}
emit() {
local iface state addr_output output_line
iface=$(find_ethernet_interface) || {
output_line=' down'
if i3blocks_update_if_changed_key "ethernet_output" "$output_line"; then
printf '%s\n' "$output_line"
fi
return 0
}
local iface state addr_output output_line
iface=$(find_ethernet_interface) || {
output_line=' down'
if i3blocks_update_if_changed_key "ethernet_output" "$output_line"; then
printf '%s\n' "$output_line"
fi
return 0
}
read -r state < "/sys/class/net/${iface}/operstate"
if [[ $state != up ]]; then
output_line=' down'
if i3blocks_update_if_changed_key "ethernet_output" "$output_line"; then
printf '%s\n' "$output_line"
fi
return 0
fi
read -r state <"/sys/class/net/${iface}/operstate"
if [[ $state != up ]]; then
output_line=' down'
if i3blocks_update_if_changed_key "ethernet_output" "$output_line"; then
printf '%s\n' "$output_line"
fi
return 0
fi
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
output_line="${BASH_REMATCH[1]}"
else
output_line=' down'
fi
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
output_line="${BASH_REMATCH[1]}"
else
output_line=' down'
fi
if i3blocks_update_if_changed_key "ethernet_output" "$output_line"; then
printf '%s\n' "$output_line"
fi
if i3blocks_update_if_changed_key "ethernet_output" "$output_line"; then
printf '%s\n' "$output_line"
fi
}
is_persist_mode() {
[[ ${BLOCK_INTERVAL:-} == "persist" ]]
[[ ${BLOCK_INTERVAL:-} == "persist" ]]
}
emit_throttled() {
if ! i3blocks_should_emit_by_interval_key "ethernet_emit" "$EMIT_MIN_INTERVAL_S"; then
return 0
fi
emit
if ! i3blocks_should_emit_by_interval_key "ethernet_emit" "$EMIT_MIN_INTERVAL_S"; then
return 0
fi
emit
}
EMIT_MIN_INTERVAL_S=2
@ -68,9 +68,9 @@ EMIT_MIN_INTERVAL_S=2
emit
if is_persist_mode; then
ip monitor link address route 2> /dev/null |
while read -r line; do
[[ $line == *"eth"* || $line == *"en"* || $line == *"inet "* ]] || continue
emit_throttled
done
ip monitor link address route 2>/dev/null |
while read -r line; do
[[ $line == *"eth"* || $line == *"en"* || $line == *"inet "* ]] || continue
emit_throttled
done
fi

View File

@ -14,81 +14,80 @@ set -u
SCRIPT_DIR=${BASH_SOURCE[0]%/*}
[[ $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"
# Nerd Font glyph: display / desktop icon (U+F108).
ICON=$'\uf108'
emit() {
local temp=$1 load=$2 color
if [[ $load == 'N/A' ]]; then
color='#FFFFFF'
elif ((load < 50)); then
color='#50FA7B'
elif ((load < 75)); then
color='#F1FA8C'
else
color='#FF5555'
fi
printf '<span color="%s">%s %s°C, %s%%</span>\n' \
"$color" "$ICON" "$temp" "$load"
local temp=$1 load=$2 color
if [[ $load == 'N/A' ]]; then
color='#FFFFFF'
elif ((load < 50)); then
color='#50FA7B'
elif ((load < 75)); then
color='#F1FA8C'
else
color='#FF5555'
fi
printf '<span color="%s">%s %s°C, %s%%</span>\n' \
"$color" "$ICON" "$temp" "$load"
}
emit_if_changed() {
local temp=$1 load=$2
if ! i3blocks_update_if_changed_key "gpu_metric" "$temp|$load"; then
return 0
fi
emit "$temp" "$load"
local temp=$1 load=$2
if ! i3blocks_update_if_changed_key "gpu_metric" "$temp|$load"; then
return 0
fi
emit "$temp" "$load"
}
# Prefer NVIDIA if present (persist via --loop).
if command -v nvidia-smi > /dev/null 2>&1; then
# One child process for the lifetime of i3blocks; emits CSV every 5s.
nvidia-smi \
--query-gpu=temperature.gpu,utilization.gpu \
--format=csv,noheader,nounits \
--loop=5 2> /dev/null |
while IFS=',' read -r temp load; do
# Strip leading/trailing whitespace using parameter expansion.
temp=${temp## }
temp=${temp%% }
load=${load## }
load=${load%% }
[[ -z $temp || -z $load ]] && continue
emit_if_changed "$temp" "$load"
done
exit 0
if command -v nvidia-smi >/dev/null 2>&1; then
# One child process for the lifetime of i3blocks; emits CSV every 5s.
nvidia-smi \
--query-gpu=temperature.gpu,utilization.gpu \
--format=csv,noheader,nounits \
--loop=5 2>/dev/null |
while IFS=',' read -r temp load; do
# Strip leading/trailing whitespace using parameter expansion.
temp=${temp## }
temp=${temp%% }
load=${load## }
load=${load%% }
[[ -z $temp || -z $load ]] && continue
emit_if_changed "$temp" "$load"
done
exit 0
fi
# AMD fallback: read sysfs directly; emit once (i3blocks restarts on exit).
amdgpu=''
for d in /sys/class/hwmon/hwmon*/; do
[[ -r ${d}name ]] || continue
read -r n < "${d}name"
[[ $n == amdgpu ]] && {
amdgpu=$d
break
}
[[ -r ${d}name ]] || continue
read -r n <"${d}name"
[[ $n == amdgpu ]] && {
amdgpu=$d
break
}
done
if [[ -n $amdgpu ]]; then
temp='N/A'
if [[ -r ${amdgpu}temp1_input ]]; then
read -r milli < "${amdgpu}temp1_input"
temp=$((milli / 1000))
fi
load='N/A'
# drm card matching the amdgpu hwmon exposes gpu_busy_percent.
for card in /sys/class/drm/card*/device/gpu_busy_percent; do
[[ -r $card ]] && {
read -r load < "$card"
break
}
done
emit_if_changed "$temp" "$load"
exit 0
temp='N/A'
if [[ -r ${amdgpu}temp1_input ]]; then
read -r milli <"${amdgpu}temp1_input"
temp=$((milli / 1000))
fi
load='N/A'
# drm card matching the amdgpu hwmon exposes gpu_busy_percent.
for card in /sys/class/drm/card*/device/gpu_busy_percent; do
[[ -r $card ]] && {
read -r load <"$card"
break
}
done
emit_if_changed "$temp" "$load"
exit 0
fi
printf '<span color="#FF5555">%s No supported GPU</span>\n' "$ICON"

View File

@ -5,67 +5,67 @@ set -euo pipefail
SCRIPT_DIR=${BASH_SOURCE[0]%/*}
[[ $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"
if ! command -v warp-cli > /dev/null 2>&1; then
echo " N/A"
exit 0
if ! command -v warp-cli >/dev/null 2>&1; then
echo " N/A"
exit 0
fi
is_persist_mode() {
[[ ${BLOCK_INTERVAL:-} == "persist" ]]
[[ ${BLOCK_INTERVAL:-} == "persist" ]]
}
WARP_POLL_INTERVAL_S=120
read_status() {
local status line
status=''
while IFS= read -r line; do
case $line in
'Status update: '*)
status=${line#Status update: }
;;
esac
done < <(warp-cli status 2> /dev/null)
printf '%s\n' "$status"
local status line
status=''
while IFS= read -r line; do
case $line in
'Status update: '*)
status=${line#Status update: }
;;
esac
done < <(warp-cli status 2>/dev/null)
printf '%s\n' "$status"
}
emit_status() {
local status=$1
if [[ $status == Connected ]]; then
echo "🔒 !!! WARP CONNECTED !!!"
echo
echo "#FFFF00"
elif [[ $status == Disconnected ]]; then
echo "WARP disconnected"
echo
echo "#00FF00"
else
echo "⚠️ ! WARP unknown !"
echo
echo "#FF0000"
fi
local status=$1
if [[ $status == Connected ]]; then
echo "🔒 !!! WARP CONNECTED !!!"
echo
echo "#FFFF00"
elif [[ $status == Disconnected ]]; then
echo "WARP disconnected"
echo
echo "#00FF00"
else
echo "⚠️ ! WARP unknown !"
echo
echo "#FF0000"
fi
}
emit_if_changed() {
local status=$1
if ! i3blocks_update_if_changed_key "warp_status" "$status"; then
return 0
fi
emit_status "$status"
local status=$1
if ! i3blocks_update_if_changed_key "warp_status" "$status"; then
return 0
fi
emit_status "$status"
}
current_status=$(read_status)
emit_status "$current_status"
if is_persist_mode; then
i3blocks_update_if_changed_key "warp_status" "$current_status" >/dev/null || true
i3blocks_update_if_changed_key "warp_status" "$current_status" >/dev/null || true
fi
if is_persist_mode; then
while true; do
i3blocks_wait_seconds "$WARP_POLL_INTERVAL_S"
current_status=$(read_status)
emit_if_changed "$current_status"
done
while true; do
i3blocks_wait_seconds "$WARP_POLL_INTERVAL_S"
current_status=$(read_status)
emit_if_changed "$current_status"
done
fi

View File

@ -5,90 +5,90 @@ set -euo pipefail
SCRIPT_DIR=${BASH_SOURCE[0]%/*}
[[ $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"
find_wifi_interface() {
local line
while IFS= read -r line; do
case $line in
*'Interface '*)
printf '%s\n' "${line##*Interface }"
return 0
;;
esac
done < <(iw dev 2> /dev/null)
return 1
local line
while IFS= read -r line; do
case $line in
*'Interface '*)
printf '%s\n' "${line##*Interface }"
return 0
;;
esac
done < <(iw dev 2>/dev/null)
return 1
}
emit() {
local wifi_interface ssid signal ip_address line fields i output_line
local wifi_interface ssid signal ip_address line fields i output_line
wifi_interface=$(find_wifi_interface) || {
output_line=' down'
if i3blocks_update_if_changed_key "wifi_output" "$output_line"; then
echo "$output_line"
fi
return 0
}
wifi_interface=$(find_wifi_interface) || {
output_line=' down'
if i3blocks_update_if_changed_key "wifi_output" "$output_line"; then
echo "$output_line"
fi
return 0
}
ssid=''
signal=''
while IFS= read -r line; do
case $line in
'SSID: '*)
ssid=${line#SSID: }
;;
'signal: '*)
signal=${line#signal: }
signal=${signal% dBm}
;;
'Not connected.'*)
ssid=''
;;
esac
done < <(iw dev "$wifi_interface" link 2> /dev/null)
ssid=''
signal=''
while IFS= read -r line; do
case $line in
'SSID: '*)
ssid=${line#SSID: }
;;
'signal: '*)
signal=${line#signal: }
signal=${signal% dBm}
;;
'Not connected.'*)
ssid=''
;;
esac
done < <(iw dev "$wifi_interface" link 2>/dev/null)
if [[ -z $ssid ]]; then
output_line=' down'
if i3blocks_update_if_changed_key "wifi_output" "$output_line"; then
echo "$output_line"
fi
return 0
fi
if [[ -z $ssid ]]; then
output_line=' down'
if i3blocks_update_if_changed_key "wifi_output" "$output_line"; then
echo "$output_line"
fi
return 0
fi
ip_address=''
while IFS= read -r line; do
[[ $line == *' inet '* ]] || continue
read -r -a fields <<< "$line"
for ((i = 0; i < ${#fields[@]}; i++)); do
if [[ ${fields[i]} == inet && $((i + 1)) -lt ${#fields[@]} ]]; then
ip_address=${fields[i + 1]%%/*}
break 2
fi
done
done < <(ip -o -4 addr show dev "$wifi_interface" scope global 2> /dev/null)
ip_address=''
while IFS= read -r line; do
[[ $line == *' inet '* ]] || continue
read -r -a fields <<<"$line"
for ((i = 0; i < ${#fields[@]}; i++)); do
if [[ ${fields[i]} == inet && $((i + 1)) -lt ${#fields[@]} ]]; then
ip_address=${fields[i + 1]%%/*}
break 2
fi
done
done < <(ip -o -4 addr show dev "$wifi_interface" scope global 2>/dev/null)
if [[ -n $ip_address ]]; then
output_line="$ssid ($signal dBm) $ip_address"
else
output_line="$ssid ($signal dBm)"
fi
if [[ -n $ip_address ]]; then
output_line="$ssid ($signal dBm) $ip_address"
else
output_line="$ssid ($signal dBm)"
fi
if i3blocks_update_if_changed_key "wifi_output" "$output_line"; then
echo "$output_line"
fi
if i3blocks_update_if_changed_key "wifi_output" "$output_line"; then
echo "$output_line"
fi
}
is_persist_mode() {
[[ ${BLOCK_INTERVAL:-} == "persist" ]]
[[ ${BLOCK_INTERVAL:-} == "persist" ]]
}
emit_throttled() {
if ! i3blocks_should_emit_by_interval_key "wifi_emit" "$EMIT_MIN_INTERVAL_S"; then
return 0
fi
emit
if ! i3blocks_should_emit_by_interval_key "wifi_emit" "$EMIT_MIN_INTERVAL_S"; then
return 0
fi
emit
}
EMIT_MIN_INTERVAL_S=2
@ -96,9 +96,9 @@ EMIT_MIN_INTERVAL_S=2
emit
if is_persist_mode; then
ip monitor link address route 2> /dev/null |
while read -r line; do
[[ $line == *"wlan"* || $line == *"wl"* || $line == *"inet "* ]] || continue
emit_throttled
done
ip monitor link address route 2>/dev/null |
while read -r line; do
[[ $line == *"wlan"* || $line == *"wl"* || $line == *"inet "* ]] || continue
emit_throttled
done
fi

View File

@ -100,7 +100,11 @@ $installed_packages
package:$package
"*)
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
done <"$BLOCKED_APPS_FILE"

View File

@ -45,7 +45,7 @@ readonly WATCHDOG_TIMER="/etc/systemd/system/dnsmasq-watchdog.timer"
# Optional DHCP-server mode (for routers that cannot advertise a custom DNS).
readonly DHCP_CONF="/etc/dnsmasq.d/lan-dhcp.conf"
readonly DHCP_LEASE="12h"
readonly DHCP_START_HOST="10" # .10 .. .150 mirrors a common router default range
readonly DHCP_START_HOST="10" # .10 .. .150 mirrors a common router default range
readonly DHCP_END_HOST="150"
# Feed generator (chattr +i but still executable) and firewall owner script.
readonly GEN="${SCRIPT_DIR}/../../periodic_background/hosts/generate_hosts_file.sh"
@ -442,10 +442,21 @@ cmd_status() {
detect_lan
echo "=== LAN DNS blocker status ==="
is_service_active dnsmasq && status_line 0 "dnsmasq: active" || status_line 1 "dnsmasq: NOT active"
is_service_enabled dnsmasq && status_line 0 "dnsmasq: enabled at boot" || status_line 1 "dnsmasq: NOT enabled"
[[ -f $DNSMASQ_DROPIN ]] && status_line 0 "Restart=always drop-in present" || status_line 1 "Restart drop-in missing"
if is_service_active dnsmasq; then
status_line 0 "dnsmasq: active"
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
status_line 0 "blocklist feed: $(wc -l <"$FEED") lines, updated $(date -r "$FEED" '+%Y-%m-%d %H:%M')"
else
@ -472,10 +483,11 @@ cmd_status() {
status_line 0 "firewall: no default-drop ruleset loaded (DNS/DHCP not filtered)"
fi
systemctl is-active dns-blocklist-refresh.timer &>/dev/null &&
status_line 0 "refresh timer: active (next $(systemctl show -p NextElapseUSecRealtime --value dns-blocklist-refresh.timer 2>/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))"
else
status_line 1 "refresh timer: NOT active"
fi
if [[ -f $DHCP_CONF ]]; 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)"
@ -492,12 +504,16 @@ cmd_status() {
local blocked passthru
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)"
[[ $blocked == "0.0.0.0" ]] &&
status_line 0 "youtube.com -> ${blocked} (BLOCKED, correct)" ||
if [[ $blocked == "0.0.0.0" ]]; then
status_line 0 "youtube.com -> ${blocked} (BLOCKED, correct)"
else
status_line 1 "youtube.com -> ${blocked:-<no answer>} (expected 0.0.0.0)"
[[ -n $passthru && $passthru != "0.0.0.0" ]] &&
status_line 0 "example.com -> ${passthru} (passthrough, correct)" ||
fi
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)"
fi
else
log_warn "install 'dig' (bind) to run the live resolution test."
fi

View File

@ -67,9 +67,12 @@ die() {
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() {
grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
exit 0
exit "${1:-0}"
}
# --- Phase 1: preflight -----------------------------------------------------
@ -394,13 +397,21 @@ check_http() {
status_cmd() {
print_setup_header "KCD2 dice solver status"
has_cmd node && status_line 0 "node present" || status_line 1 "node missing"
has_cmd pnpm && status_line 0 "pnpm present" || status_line 1 "pnpm missing"
[[ -f "${DICE_SRC}/dist/index.html" ]] &&
status_line 0 "build present (dist/index.html)" ||
if has_cmd node; then
status_line 0 "node present"
else
status_line 1 "node missing"
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"
fi
if docker ps --format '{{.Names}}' | grep -qx "$DICE_CONTAINER"; then
status_line 0 "${DICE_CONTAINER} container running"
local pid listeners
@ -418,14 +429,16 @@ status_cmd() {
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})"
[[ -f $DICE_SNIPPET ]] &&
status_line 0 "Caddy snippet present" ||
if [[ -f $DICE_SNIPPET ]]; then
status_line 0 "Caddy snippet present"
else
status_line 1 "Caddy snippet missing"
getent hosts "$DICE_DOMAIN" >/dev/null &&
status_line 0 "${DICE_DOMAIN} resolves" ||
fi
if getent hosts "$DICE_DOMAIN" >/dev/null; then
status_line 0 "${DICE_DOMAIN} resolves"
else
status_line 1 "${DICE_DOMAIN} does not resolve"
fi
status_line "$(check_http "https://${DICE_DOMAIN}/")" \
"external https://${DICE_DOMAIN}/"
@ -438,10 +451,11 @@ status_cmd() {
status_line 1 "post-commit hook missing — re-run setup"
fi
[[ -f $UNIT_FILE ]] &&
status_line 0 "${UNIT_NAME} installed" ||
if [[ -f $UNIT_FILE ]]; then
status_line 0 "${UNIT_NAME} installed"
else
status_line 1 "${UNIT_NAME} missing — re-run setup"
fi
if systemctl --user is-failed --quiet "$UNIT_NAME"; then
status_line 1 "last auto-rebuild FAILED — journalctl --user -u ${UNIT_NAME}"
else
@ -475,8 +489,7 @@ main() {
;;
*)
log_error "Unknown command: $cmd"
usage
exit 1
usage 1
;;
esac
}

View File

@ -66,9 +66,13 @@ die() {
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() {
grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
exit 0
exit "${1:-0}"
}
# --- Phase 1: preflight -----------------------------------------------------
@ -320,13 +324,21 @@ check_http() {
status_cmd() {
print_setup_header "Personal website status"
has_cmd node && status_line 0 "node present" || status_line 1 "node missing"
has_cmd pnpm && status_line 0 "pnpm present" || status_line 1 "pnpm missing"
[[ -f "${WEBSITE_SRC}/dist/index.html" ]] &&
status_line 0 "build present (dist/index.html)" ||
if has_cmd node; then
status_line 0 "node present"
else
status_line 1 "node missing"
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"
fi
if docker ps --format '{{.Names}}' | grep -qx personal-website; then
status_line 0 "personal-website container running"
else
@ -336,20 +348,26 @@ status_cmd() {
status_line "$(check_http "http://127.0.0.1:${WEBSITE_PORT}/")" \
"local static server (127.0.0.1:${WEBSITE_PORT})"
[[ -f $WEBSITE_SNIPPET ]] &&
status_line 0 "Caddy website snippet present" ||
if [[ -f $WEBSITE_SNIPPET ]]; then
status_line 0 "Caddy website snippet present"
else
status_line 1 "Caddy website snippet missing"
grep -qE '^[[:space:]]*import[[:space:]]+/etc/caddy/sites' "$CADDYFILE" 2>/dev/null &&
status_line 0 "root Caddyfile uses snippet imports" ||
fi
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"
getent hosts "$WEBSITE_DOMAIN" >/dev/null &&
status_line 0 "${WEBSITE_DOMAIN} resolves" ||
fi
if getent hosts "$WEBSITE_DOMAIN" >/dev/null; then
status_line 0 "${WEBSITE_DOMAIN} resolves"
else
status_line 1 "${WEBSITE_DOMAIN} does not resolve"
getent hosts "$GITEA_DOMAIN" >/dev/null &&
status_line 0 "${GITEA_DOMAIN} resolves" ||
fi
if getent hosts "$GITEA_DOMAIN" >/dev/null; then
status_line 0 "${GITEA_DOMAIN} resolves"
else
status_line 1 "${GITEA_DOMAIN} does not resolve"
fi
status_line "$(check_http "https://${WEBSITE_DOMAIN}/")" \
"external https://${WEBSITE_DOMAIN}/"
status_line "$(check_http "https://${GITEA_DOMAIN}/")" \
@ -370,8 +388,7 @@ main() {
;;
*)
log_error "Unknown command: $cmd"
usage
exit 1
usage 1
;;
esac
}

View File

@ -145,19 +145,23 @@ collect_basics() {
}
check_cpu_governor() {
local gov_files
gov_files=$(find /sys/devices/system/cpu -maxdepth 3 -name scaling_governor 2>/dev/null || true)
# An ARRAY, not a newline-joined string: the awk/grep calls below need one
# 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)."
return
fi
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"
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."
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)
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 power: ${power:-unknown}W" >>"$REPORT_FILE"
{
echo "NVIDIA pstate: ${pstate:-unknown}"
echo "NVIDIA util: ${util:-unknown}%"
echo "NVIDIA power: ${power:-unknown}W"
} >>"$REPORT_FILE"
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."

View File

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

View File

@ -26,11 +26,11 @@ SCRIPT_NAME="$(basename "$0")"
# Source common library for log functions
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# shellcheck source=../../lib/common.sh
source "$SCRIPT_DIR/../../lib/common.sh"
# shellcheck source=../../../lib/common.sh
source "$SCRIPT_DIR/../../../lib/common.sh"
usage() {
cat << EOF
cat <<EOF
${SCRIPT_NAME} - Fix Unity Hub sign-in by registering unityhub:// URL handler
Options:
@ -47,158 +47,158 @@ AUTO_INSTALL=false
RUN_TEST=false
while [[ $# -gt 0 ]]; do
case "$1" in
-y | --yes)
AUTO_INSTALL=true
shift
;;
--test)
RUN_TEST=true
shift
;;
-h | --help)
usage
exit 0
;;
*)
log_error "Unknown argument: $1"
usage
exit 1
;;
esac
case "$1" in
-y | --yes)
AUTO_INSTALL=true
shift
;;
--test)
RUN_TEST=true
shift
;;
-h | --help)
usage
exit 0
;;
*)
log_error "Unknown argument: $1"
usage
exit 1
;;
esac
done
require_cmd() {
if ! command -v "$1" > /dev/null 2>&1; then
return 1
fi
if ! command -v "$1" >/dev/null 2>&1; then
return 1
fi
}
ensure_deps_arch() {
# Best-effort install for Arch-based systems
if [[ $AUTO_INSTALL != true ]]; then
log_warn "Skipping package installation (use -y to auto-install)."
return 0
fi
if ! require_cmd pacman; then
log_warn "Not an Arch-based system (pacman not found). Skipping auto-install."
return 0
fi
local pkgs=(xdg-utils desktop-file-utils xdg-desktop-portal xdg-desktop-portal-gtk)
log_info "Installing/ensuring packages: ${pkgs[*]}"
if ! require_cmd sudo; then
log_warn "sudo not found; attempting pacman directly (may fail)."
sudo_cmd=""
else
sudo_cmd="sudo"
fi
# Use --needed to avoid reinstalling
set +e
$sudo_cmd pacman -S --needed --noconfirm "${pkgs[@]}"
local rc=$?
set -e
if [[ $rc -ne 0 ]]; then
log_warn "Package install may have failed or been partial. Continuing anyway."
else
log_ok "Dependencies installed/verified."
fi
# Best-effort install for Arch-based systems
if [[ $AUTO_INSTALL != true ]]; then
log_warn "Skipping package installation (use -y to auto-install)."
return 0
fi
if ! require_cmd pacman; then
log_warn "Not an Arch-based system (pacman not found). Skipping auto-install."
return 0
fi
local pkgs=(xdg-utils desktop-file-utils xdg-desktop-portal xdg-desktop-portal-gtk)
log_info "Installing/ensuring packages: ${pkgs[*]}"
if ! require_cmd sudo; then
log_warn "sudo not found; attempting pacman directly (may fail)."
sudo_cmd=""
else
sudo_cmd="sudo"
fi
# Use --needed to avoid reinstalling
set +e
$sudo_cmd pacman -S --needed --noconfirm "${pkgs[@]}"
local rc=$?
set -e
if [[ $rc -ne 0 ]]; then
log_warn "Package install may have failed or been partial. Continuing anyway."
else
log_ok "Dependencies installed/verified."
fi
}
desktop_dir="$HOME/.local/share/applications"
mkdir -p "$desktop_dir"
detect_unityhub() {
# Outputs: INSTALL_TYPE (FLATPAK|NATIVE|APPIMAGE|UNKNOWN) and EXEC_CMD
local install_type="UNKNOWN" exec_cmd=""
# Outputs: INSTALL_TYPE (FLATPAK|NATIVE|APPIMAGE|UNKNOWN) and EXEC_CMD
local install_type="UNKNOWN" exec_cmd=""
# 1) Flatpak
if command -v flatpak > /dev/null 2>&1; then
if flatpak info com.unity.UnityHub > /dev/null 2>&1; then
install_type="FLATPAK"
exec_cmd="flatpak run com.unity.UnityHub %U"
echo "$install_type|$exec_cmd"
return 0
fi
fi
# 1) Flatpak
if command -v flatpak >/dev/null 2>&1; then
if flatpak info com.unity.UnityHub >/dev/null 2>&1; then
install_type="FLATPAK"
exec_cmd="flatpak run com.unity.UnityHub %U"
echo "$install_type|$exec_cmd"
return 0
fi
fi
# 2) Native binary in PATH
if command -v unityhub > /dev/null 2>&1; then
local path
path="$(command -v unityhub)"
install_type="NATIVE"
exec_cmd="$path %U"
echo "$install_type|$exec_cmd"
return 0
fi
# 2) Native binary in PATH
if command -v unityhub >/dev/null 2>&1; then
local path
path="$(command -v unityhub)"
install_type="NATIVE"
exec_cmd="$path %U"
echo "$install_type|$exec_cmd"
return 0
fi
# 3) Search desktop files for Unity Hub Exec
local search_dirs=(
"$HOME/.local/share/applications"
"/usr/share/applications"
"/var/lib/flatpak/exports/share/applications"
"$HOME/.local/share/flatpak/exports/share/applications"
)
local found_exec=""
for d in "${search_dirs[@]}"; do
[[ -d $d ]] || continue
# prefer official naming when present
local f
for f in "$d"/*.desktop; do
[[ -e $f ]] || continue
if grep -qiE '^(Name|Comment)=.*Unity Hub' "$f" 2> /dev/null ||
grep -qiE 'Exec=.*unityhub' "$f" 2> /dev/null; then
local exec_line
exec_line="$(grep -iE '^Exec=' "$f" | head -n1 | sed 's/^Exec=//')"
if [[ -n $exec_line ]]; then
found_exec="$exec_line"
break 2
fi
fi
done
done
# 3) Search desktop files for Unity Hub Exec
local search_dirs=(
"$HOME/.local/share/applications"
"/usr/share/applications"
"/var/lib/flatpak/exports/share/applications"
"$HOME/.local/share/flatpak/exports/share/applications"
)
local found_exec=""
for d in "${search_dirs[@]}"; do
[[ -d $d ]] || continue
# prefer official naming when present
local f
for f in "$d"/*.desktop; do
[[ -e $f ]] || continue
if grep -qiE '^(Name|Comment)=.*Unity Hub' "$f" 2>/dev/null ||
grep -qiE 'Exec=.*unityhub' "$f" 2>/dev/null; then
local exec_line
exec_line="$(grep -iE '^Exec=' "$f" | head -n1 | sed 's/^Exec=//')"
if [[ -n $exec_line ]]; then
found_exec="$exec_line"
break 2
fi
fi
done
done
if [[ -n $found_exec ]]; then
# Normalize: ensure %U present
if [[ $found_exec != *"%U"* && $found_exec != *"%u"* ]]; then
found_exec+=" %U"
fi
if [[ $found_exec == flatpak* ]]; then
install_type="FLATPAK"
elif [[ $found_exec == *AppImage* || $found_exec == *appimage* ]]; then
install_type="APPIMAGE"
else
install_type="NATIVE"
fi
echo "$install_type|$found_exec"
return 0
fi
if [[ -n $found_exec ]]; then
# Normalize: ensure %U present
if [[ $found_exec != *"%U"* && $found_exec != *"%u"* ]]; then
found_exec+=" %U"
fi
if [[ $found_exec == flatpak* ]]; then
install_type="FLATPAK"
elif [[ $found_exec == *AppImage* || $found_exec == *appimage* ]]; then
install_type="APPIMAGE"
else
install_type="NATIVE"
fi
echo "$install_type|$found_exec"
return 0
fi
# 4) Try common AppImage locations
local ai_candidates=(
"$HOME/Applications/UnityHub*.AppImage"
"$HOME/.local/bin/UnityHub*.AppImage"
"/opt/UnityHub*/UnityHub*.AppImage"
)
local ai
for ai in "${ai_candidates[@]}"; do
for p in $ai; do
if [[ -f $p && -x $p ]]; then
install_type="APPIMAGE"
exec_cmd="$p %U"
echo "$install_type|$exec_cmd"
return 0
fi
done
done
# 4) Try common AppImage locations
local ai_candidates=(
"$HOME/Applications/UnityHub*.AppImage"
"$HOME/.local/bin/UnityHub*.AppImage"
"/opt/UnityHub*/UnityHub*.AppImage"
)
local ai
for ai in "${ai_candidates[@]}"; do
for p in $ai; do
if [[ -f $p && -x $p ]]; then
install_type="APPIMAGE"
exec_cmd="$p %U"
echo "$install_type|$exec_cmd"
return 0
fi
done
done
echo "$install_type|$exec_cmd"
echo "$install_type|$exec_cmd"
}
create_handler_desktop() {
local exec_cmd="$1"
local dest="$desktop_dir/unityhub-url-handler.desktop"
log_info "Writing handler desktop entry: $dest"
cat > "$dest" << DESK
local exec_cmd="$1"
local dest="$desktop_dir/unityhub-url-handler.desktop"
log_info "Writing handler desktop entry: $dest"
cat >"$dest" <<DESK
[Desktop Entry]
Name=Unity Hub URL Handler
Comment=Handle unityhub:// links for Unity Hub sign-in
@ -211,82 +211,82 @@ StartupWMClass=Unity Hub
MimeType=x-scheme-handler/unityhub;x-scheme-handler/unity;
NoDisplay=true
DESK
log_ok "Desktop entry created/updated."
echo "$dest"
log_ok "Desktop entry created/updated."
echo "$dest"
}
register_mime_handler() {
local desktop_file="$1"
# Update desktop database if available
if command -v update-desktop-database > /dev/null 2>&1; then
update-desktop-database "$desktop_dir" || true
else
log_warn "update-desktop-database not found (install desktop-file-utils)."
fi
local desktop_file="$1"
# Update desktop database if available
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$desktop_dir" || true
else
log_warn "update-desktop-database not found (install desktop-file-utils)."
fi
# Register as default handler for both schemes
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/unity || true
else
log_error "xdg-mime not found (install xdg-utils)."
return 1
fi
log_ok "MIME handler registered for unityhub:// (and unity://)."
# Register as default handler for both schemes
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/unity || true
else
log_error "xdg-mime not found (install xdg-utils)."
return 1
fi
log_ok "MIME handler registered for unityhub:// (and unity://)."
}
verify_registration() {
local expected cur1 cur2
expected="$(basename "$1")"
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)"
log_info "Current handler (unityhub): ${cur1:-<none>}"
log_info "Current handler (unity): ${cur2:-<none>}"
if [[ $cur1 == "$expected" ]]; then
log_ok "unityhub scheme correctly set to $expected"
else
log_warn "unityhub scheme not set to $expected (currently: ${cur1:-none})."
fi
local expected cur1 cur2
expected="$(basename "$1")"
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)"
log_info "Current handler (unityhub): ${cur1:-<none>}"
log_info "Current handler (unity): ${cur2:-<none>}"
if [[ $cur1 == "$expected" ]]; then
log_ok "unityhub scheme correctly set to $expected"
else
log_warn "unityhub scheme not set to $expected (currently: ${cur1:-none})."
fi
}
maybe_test_open() {
if [[ $RUN_TEST == true ]]; then
log_info "Opening test link: unityhub://v1/editor-signin"
if command -v xdg-open > /dev/null 2>&1; then
xdg-open 'unityhub://v1/editor-signin' > /dev/null 2>&1 || true
log_ok "Test link invoked. Check if Unity Hub launches or focuses."
else
log_warn "xdg-open not found; cannot run test automatically."
fi
else
log_info "You can test manually with: xdg-open 'unityhub://v1/editor-signin'"
fi
if [[ $RUN_TEST == true ]]; then
log_info "Opening test link: unityhub://v1/editor-signin"
if command -v xdg-open >/dev/null 2>&1; then
xdg-open 'unityhub://v1/editor-signin' >/dev/null 2>&1 || true
log_ok "Test link invoked. Check if Unity Hub launches or focuses."
else
log_warn "xdg-open not found; cannot run test automatically."
fi
else
log_info "You can test manually with: xdg-open 'unityhub://v1/editor-signin'"
fi
}
main() {
log_info "Ensuring required tools (optional)."
ensure_deps_arch
log_info "Ensuring required tools (optional)."
ensure_deps_arch
log_info "Detecting Unity Hub installation..."
IFS='|' read -r install_type exec_cmd < <(detect_unityhub)
log_info "Detected type: $install_type"
if [[ -z ${exec_cmd:-} ]]; then
log_warn "Could not find Unity Hub executable automatically."
log_warn "- If using Flatpak: install with 'flatpak install flathub com.unity.UnityHub'"
log_warn "- If native (AUR): ensure 'unityhub' is in PATH"
log_warn "- If AppImage: place it in ~/Applications and make it executable"
log_error "Aborting—no Exec command available to create handler."
exit 2
fi
log_info "Using Exec: $exec_cmd"
log_info "Detecting Unity Hub installation..."
IFS='|' read -r install_type exec_cmd < <(detect_unityhub)
log_info "Detected type: $install_type"
if [[ -z ${exec_cmd:-} ]]; then
log_warn "Could not find Unity Hub executable automatically."
log_warn "- If using Flatpak: install with 'flatpak install flathub com.unity.UnityHub'"
log_warn "- If native (AUR): ensure 'unityhub' is in PATH"
log_warn "- If AppImage: place it in ~/Applications and make it executable"
log_error "Aborting—no Exec command available to create handler."
exit 2
fi
log_info "Using Exec: $exec_cmd"
local desktop_file
desktop_file="$(create_handler_desktop "$exec_cmd")"
local desktop_file
desktop_file="$(create_handler_desktop "$exec_cmd")"
register_mime_handler "$desktop_file"
verify_registration "$desktop_file"
register_mime_handler "$desktop_file"
verify_registration "$desktop_file"
cat << 'NOTE'
cat <<'NOTE'
---
Next steps:
- Sign in from Unity Hub. When the browser finishes, ALLOW the prompt to open xdg-open/Unity Hub.
@ -294,9 +294,9 @@ Next steps:
---
NOTE
maybe_test_open
maybe_test_open
log_ok "Done. If login still fails, check the Hub's logs and share the outputs of:\n which unityhub || true\n flatpak info com.unity.UnityHub 2>/dev/null | sed -n '1,5p' || true\n xdg-mime query default x-scheme-handler/unityhub\n grep -R \"x-scheme-handler/unityhub\" ~/.local/share/applications /usr/share/applications 2>/dev/null | head -n 10"
log_ok "Done. If login still fails, check the Hub's logs and share the outputs of:\n which unityhub || true\n flatpak info com.unity.UnityHub 2>/dev/null | sed -n '1,5p' || true\n xdg-mime query default x-scheme-handler/unityhub\n grep -R \"x-scheme-handler/unityhub\" ~/.local/share/applications /usr/share/applications 2>/dev/null | head -n 10"
}
main "$@"

View File

@ -13,21 +13,21 @@ set -euo pipefail
# Source common library for shared functions
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# shellcheck source=../../lib/common.sh
source "$SCRIPT_DIR/../../lib/common.sh"
# shellcheck source=../../../lib/common.sh
source "$SCRIPT_DIR/../../../lib/common.sh"
YES=false
while [[ $# -gt 0 ]]; do
case "$1" in
-y | --yes)
YES=true
shift
;;
*)
echo "Unknown option: $1" >&2
exit 2
;;
esac
case "$1" in
-y | --yes)
YES=true
shift
;;
*)
echo "Unknown option: $1" >&2
exit 2
;;
esac
done
RN_TARGET_DIR=${RN_TARGET_DIR:-"$(dirname "$0")/models"}
@ -37,125 +37,125 @@ mkdir -p "$RN_TARGET_DIR"
dest="$RN_TARGET_DIR/$RN_TARGET_NAME"
if [[ -f $dest ]]; then
echo "Model already exists at: $dest"
exit 0
echo "Model already exists at: $dest"
exit 0
fi
if ! $YES; then
if ! ask_yes_no "Download RNNoise model to $dest?"; then
echo "Aborted."
exit 1
fi
if ! ask_yes_no "Download RNNoise model to $dest?"; then
echo "Aborted."
exit 1
fi
fi
if ! has_cmd curl && ! has_cmd wget; then
echo "Error: Need curl or wget to download RNNoise model." >&2
exit 3
echo "Error: Need curl or wget to download RNNoise model." >&2
exit 3
fi
# Helper: try to download a URL to destination, exit 0 on success
# Usage: try_download_model URL DEST
try_download_model() {
local url="$1"
local dest="$2"
local tmp
tmp=$(mktemp)
echo "Attempting to download RNNoise model from: $url" >&2
if has_cmd curl; then
curl -fsSL "$url" -o "$tmp" 2> /dev/null || true
else
wget -qO "$tmp" "$url" 2> /dev/null || true
fi
if [[ -s $tmp ]]; then
mv "$tmp" "$dest"
echo "Saved RNNoise model to: $dest" >&2
exit 0
fi
rm -f "$tmp" || true
local url="$1"
local dest="$2"
local tmp
tmp=$(mktemp)
echo "Attempting to download RNNoise model from: $url" >&2
if has_cmd curl; then
curl -fsSL "$url" -o "$tmp" 2>/dev/null || true
else
wget -qO "$tmp" "$url" 2>/dev/null || true
fi
if [[ -s $tmp ]]; then
mv "$tmp" "$dest"
echo "Saved RNNoise model to: $dest" >&2
exit 0
fi
rm -f "$tmp" || true
}
# Priority 1: explicit URL
if [[ -n ${RN_URL:-} ]]; then
echo "Downloading RNNoise model from RN_URL: $RN_URL" >&2
try_download_model "$RN_URL" "$dest"
echo "Warning: RN_URL download failed; continuing to fallback sources." >&2
echo "Downloading RNNoise model from RN_URL: $RN_URL" >&2
try_download_model "$RN_URL" "$dest"
echo "Warning: RN_URL download failed; continuing to fallback sources." >&2
fi
# Priority 2: rnnoise-nu known models (GregorR)
NU_URLS=(
"https://raw.githubusercontent.com/GregorR/rnnoise-nu/master/src/models/sh.rnnn"
"https://raw.githubusercontent.com/GregorR/rnnoise-nu/master/src/models/lq.rnnn"
"https://raw.githubusercontent.com/GregorR/rnnoise-nu/master/src/models/mp.rnnn"
"https://raw.githubusercontent.com/GregorR/rnnoise-nu/master/src/models/bd.rnnn"
"https://raw.githubusercontent.com/GregorR/rnnoise-nu/master/src/models/cb.rnnn"
"https://raw.githubusercontent.com/GregorR/rnnoise-nu/master/src/models/sh.rnnn"
"https://raw.githubusercontent.com/GregorR/rnnoise-nu/master/src/models/lq.rnnn"
"https://raw.githubusercontent.com/GregorR/rnnoise-nu/master/src/models/mp.rnnn"
"https://raw.githubusercontent.com/GregorR/rnnoise-nu/master/src/models/bd.rnnn"
"https://raw.githubusercontent.com/GregorR/rnnoise-nu/master/src/models/cb.rnnn"
)
for u in "${NU_URLS[@]}"; do
try_download_model "$u" "$dest"
try_download_model "$u" "$dest"
done
# Priority 2b: arnndn-models fallback (richardpl)
RNNDN_URLS=(
"https://raw.githubusercontent.com/richardpl/arnndn-models/master/sh.rnnn"
"https://raw.githubusercontent.com/richardpl/arnndn-models/master/sh.rnnn"
)
for u in "${RNNDN_URLS[@]}"; do
try_download_model "$u" "$dest"
try_download_model "$u" "$dest"
done
# Priority 3: repo archives (rnnoise-nu and arnndn-models)
ARCHIVES=(
"https://github.com/GregorR/rnnoise-nu/archive/refs/heads/master.zip"
"https://github.com/richardpl/arnndn-models/archive/refs/heads/master.zip"
"https://github.com/GregorR/rnnoise-nu/archive/refs/heads/master.zip"
"https://github.com/richardpl/arnndn-models/archive/refs/heads/master.zip"
)
for aurl in "${ARCHIVES[@]}"; do
echo "Attempting to download archive: $aurl" >&2
tmpdir=$(mktemp -d)
archive="$tmpdir/models.zip"
set +e
if has_cmd curl; then
curl -fL "$aurl" -o "$archive"
else
wget -O "$archive" "$aurl"
fi
status=$?
set -e
if [[ $status -ne 0 ]]; then
rm -rf "$tmpdir" || true
continue
fi
if has_cmd bsdtar; then
bsdtar -xf "$archive" -C "$tmpdir"
elif has_cmd unzip; then
unzip -q "$archive" -d "$tmpdir"
else
echo "Warning: Need bsdtar or unzip to extract archive; skipping archive method." >&2
rm -rf "$tmpdir" || true
continue
fi
mapfile -t nnfiles < <(bash -lc 'shopt -s globstar nullglob; for f in '"$tmpdir"'/**/*.rnnn '"$tmpdir"'/**/*.nn; do [[ -f "$f" ]] && echo "$f"; done')
if [[ ${#nnfiles[@]} -gt 0 ]]; then
cp -f "${nnfiles[0]}" "$dest"
echo "Saved RNNoise model to: $dest (from archive)" >&2
rm -rf "$tmpdir" || true
exit 0
fi
rm -rf "$tmpdir" || true
echo "Attempting to download archive: $aurl" >&2
tmpdir=$(mktemp -d)
archive="$tmpdir/models.zip"
set +e
if has_cmd curl; then
curl -fL "$aurl" -o "$archive"
else
wget -O "$archive" "$aurl"
fi
status=$?
set -e
if [[ $status -ne 0 ]]; then
rm -rf "$tmpdir" || true
continue
fi
if has_cmd bsdtar; then
bsdtar -xf "$archive" -C "$tmpdir"
elif has_cmd unzip; then
unzip -q "$archive" -d "$tmpdir"
else
echo "Warning: Need bsdtar or unzip to extract archive; skipping archive method." >&2
rm -rf "$tmpdir" || true
continue
fi
mapfile -t nnfiles < <(bash -lc 'shopt -s globstar nullglob; for f in '"$tmpdir"'/**/*.rnnn '"$tmpdir"'/**/*.nn; do [[ -f "$f" ]] && echo "$f"; done')
if [[ ${#nnfiles[@]} -gt 0 ]]; then
cp -f "${nnfiles[0]}" "$dest"
echo "Saved RNNoise model to: $dest (from archive)" >&2
rm -rf "$tmpdir" || true
exit 0
fi
rm -rf "$tmpdir" || true
done
# Priority 4: Arch-based AUR packages and search only .nn/.rnnn
if has_cmd yay; then
echo "Attempting to install AUR packages that may include RNNoise models..." >&2
set +e
yay -S --noconfirm denoiseit-git 2> /dev/null
yay -S --noconfirm speech-denoiser-git 2> /dev/null
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)
if [[ ${#found[@]} -gt 0 ]]; then
echo "Found candidate models:" >&2
printf ' %s\n' "${found[@]}" >&2
cp -f "${found[0]}" "$dest"
echo "Copied model to: $dest" >&2
exit 0
fi
echo "Attempting to install AUR packages that may include RNNoise models..." >&2
set +e
yay -S --noconfirm denoiseit-git 2>/dev/null
yay -S --noconfirm speech-denoiser-git 2>/dev/null
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)
if [[ ${#found[@]} -gt 0 ]]; then
echo "Found candidate models:" >&2
printf ' %s\n' "${found[@]}" >&2
cp -f "${found[0]}" "$dest"
echo "Copied model to: $dest" >&2
exit 0
fi
fi
echo "Error: Could not obtain an RNNoise model automatically." >&2

View File

@ -9,117 +9,117 @@ set -euo pipefail
# Source common library for shared functions
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# shellcheck source=../../lib/common.sh
source "$SCRIPT_DIR/../../lib/common.sh"
# shellcheck source=../../../lib/common.sh
source "$SCRIPT_DIR/../../../lib/common.sh"
print_info() {
echo "[info] $*"
echo "[info] $*"
}
detect_distro() {
if [[ -f /etc/os-release ]]; then
. /etc/os-release
echo "${ID:-unknown}"
else
echo "unknown"
fi
if [[ -f /etc/os-release ]]; then
. /etc/os-release
echo "${ID:-unknown}"
else
echo "unknown"
fi
}
main() {
local distro
distro=$(detect_distro)
print_info "Detected distro: $distro"
local distro
distro=$(detect_distro)
print_info "Detected distro: $distro"
if has_cmd ffmpeg && ffmpeg -hide_banner -filters | grep -q " arnndn "; then
print_info "Your ffmpeg already supports arnndn."
else
case "$distro" in
ubuntu | debian)
print_info "On Ubuntu/Debian, the official repo may lack newer filters. Consider a PPA or build from source."
echo "Options:"
echo " - ppa: sudo add-apt-repository ppa:savoury1/ffmpeg6 && sudo apt update && sudo apt install ffmpeg"
echo " - source build (recommended for latest): run this script to build from source"
;;
arch | manjaro | endeavouros)
print_info "On Arch-based distros, ffmpeg is recent. Try: sudo pacman -Syu ffmpeg"
;;
fedora)
print_info "On Fedora, try: sudo dnf install ffmpeg"
;;
*)
print_info "Distro not recognized; will offer source build."
;;
esac
fi
if has_cmd ffmpeg && ffmpeg -hide_banner -filters | grep -q " arnndn "; then
print_info "Your ffmpeg already supports arnndn."
else
case "$distro" in
ubuntu | debian)
print_info "On Ubuntu/Debian, the official repo may lack newer filters. Consider a PPA or build from source."
echo "Options:"
echo " - ppa: sudo add-apt-repository ppa:savoury1/ffmpeg6 && sudo apt update && sudo apt install ffmpeg"
echo " - source build (recommended for latest): run this script to build from source"
;;
arch | manjaro | endeavouros)
print_info "On Arch-based distros, ffmpeg is recent. Try: sudo pacman -Syu ffmpeg"
;;
fedora)
print_info "On Fedora, try: sudo dnf install ffmpeg"
;;
*)
print_info "Distro not recognized; will offer source build."
;;
esac
fi
if ask_yes_no "Build FFmpeg from source with rnnoise/arnndn support now?"; then
echo "This will clone FFmpeg and build locally under ./ffmpeg-build. Continue?"
if ! ask_yes_no "Proceed"; then
exit 0
fi
set -x
mkdir -p ffmpeg-build && cd ffmpeg-build
# Prepare repository
if [[ -d FFmpeg ]]; then
if [[ -d FFmpeg/.git ]]; then
if ask_yes_no "An existing FFmpeg source directory was found. Reuse and update it?"; then
set +e
git -C FFmpeg fetch --all --tags --prune
git -C FFmpeg pull --rebase --ff-only || true
set -e
else
if ask_yes_no "Delete existing FFmpeg directory and re-clone?"; then
rm -rf FFmpeg
else
echo "Keeping existing FFmpeg directory as-is."
fi
fi
else
if ask_yes_no "Non-git 'FFmpeg' directory exists. Delete and re-clone?"; then
rm -rf FFmpeg
else
echo "Cannot proceed with a non-git FFmpeg directory present. Aborting."
exit 4
fi
fi
fi
# Dependencies
if [[ $distro == "ubuntu" || $distro == "debian" ]]; then
sudo apt update
sudo apt install -y git build-essential yasm nasm pkg-config libx264-dev libx265-dev libvpx-dev libopus-dev libfdk-aac-dev libmp3lame-dev libvorbis-dev libass-dev libfreetype6-dev libgnutls28-dev libaom-dev libdav1d-dev libxvidcore-dev libxcb1-dev libxcb-shm0-dev libxcb-xfixes0-dev libxcb-shape0-dev libdrm-dev libvulkan-dev libva-dev libvdpau-dev librtmp-dev libunistring-dev libgnutls28-dev libchromaprint-dev libbluray-dev librubberband-dev libspeex-dev libsoxr-dev libvmaf-dev libzimg-dev libsvtav1-dev libtheora-dev libwebp-dev libopenal-dev libjack-jackd2-dev libpulse-dev librnnoise-dev
elif [[ $distro == "arch" || $distro == "manjaro" || $distro == "endeavouros" ]]; then
sudo pacman -Syu --needed base-devel yasm nasm pkgconf rnnoise
elif [[ $distro == "fedora" ]]; then
sudo dnf install -y git make gcc yasm nasm pkgconf-pkg-config rnnoise-devel libX11-devel libXext-devel libXfixes-devel libXv-devel libXrandr-devel libXi-devel libXtst-devel libXinerama-devel freetype-devel fontconfig-devel libass-devel libvpx-devel libaom-devel libdav1d-devel zimg-devel rubberband-devel soxr-devel libvorbis-devel opus-devel lame-devel
else
echo "Note: please ensure rnnoise development headers are installed (pkg-config rnnoise)." >&2
fi
if [[ ! -d FFmpeg/.git ]]; then
git clone https://github.com/FFmpeg/FFmpeg.git --depth=1
fi
cd FFmpeg
RN_FLAG=""
# Some FFmpeg versions auto-detect rnnoise without a flag; include the flag only if supported
if ./configure --help | grep -q "librnnoise"; then
RN_FLAG="--enable-librnnoise"
else
echo "[info] configure has no --enable-librnnoise; relying on auto-detection via pkg-config (rnnoise)." >&2
fi
if ask_yes_no "Build FFmpeg from source with rnnoise/arnndn support now?"; then
echo "This will clone FFmpeg and build locally under ./ffmpeg-build. Continue?"
if ! ask_yes_no "Proceed"; then
exit 0
fi
set -x
mkdir -p ffmpeg-build && cd ffmpeg-build
# Prepare repository
if [[ -d FFmpeg ]]; then
if [[ -d FFmpeg/.git ]]; then
if ask_yes_no "An existing FFmpeg source directory was found. Reuse and update it?"; then
set +e
git -C FFmpeg fetch --all --tags --prune
git -C FFmpeg pull --rebase --ff-only || true
set -e
else
if ask_yes_no "Delete existing FFmpeg directory and re-clone?"; then
rm -rf FFmpeg
else
echo "Keeping existing FFmpeg directory as-is."
fi
fi
else
if ask_yes_no "Non-git 'FFmpeg' directory exists. Delete and re-clone?"; then
rm -rf FFmpeg
else
echo "Cannot proceed with a non-git FFmpeg directory present. Aborting."
exit 4
fi
fi
fi
# Dependencies
if [[ $distro == "ubuntu" || $distro == "debian" ]]; then
sudo apt update
sudo apt install -y git build-essential yasm nasm pkg-config libx264-dev libx265-dev libvpx-dev libopus-dev libfdk-aac-dev libmp3lame-dev libvorbis-dev libass-dev libfreetype6-dev libgnutls28-dev libaom-dev libdav1d-dev libxvidcore-dev libxcb1-dev libxcb-shm0-dev libxcb-xfixes0-dev libxcb-shape0-dev libdrm-dev libvulkan-dev libva-dev libvdpau-dev librtmp-dev libunistring-dev libgnutls28-dev libchromaprint-dev libbluray-dev librubberband-dev libspeex-dev libsoxr-dev libvmaf-dev libzimg-dev libsvtav1-dev libtheora-dev libwebp-dev libopenal-dev libjack-jackd2-dev libpulse-dev librnnoise-dev
elif [[ $distro == "arch" || $distro == "manjaro" || $distro == "endeavouros" ]]; then
sudo pacman -Syu --needed base-devel yasm nasm pkgconf rnnoise
elif [[ $distro == "fedora" ]]; then
sudo dnf install -y git make gcc yasm nasm pkgconf-pkg-config rnnoise-devel libX11-devel libXext-devel libXfixes-devel libXv-devel libXrandr-devel libXi-devel libXtst-devel libXinerama-devel freetype-devel fontconfig-devel libass-devel libvpx-devel libaom-devel libdav1d-devel zimg-devel rubberband-devel soxr-devel libvorbis-devel opus-devel lame-devel
else
echo "Note: please ensure rnnoise development headers are installed (pkg-config rnnoise)." >&2
fi
if [[ ! -d FFmpeg/.git ]]; then
git clone https://github.com/FFmpeg/FFmpeg.git --depth=1
fi
cd FFmpeg
RN_FLAG=""
# Some FFmpeg versions auto-detect rnnoise without a flag; include the flag only if supported
if ./configure --help | grep -q "librnnoise"; then
RN_FLAG="--enable-librnnoise"
else
echo "[info] configure has no --enable-librnnoise; relying on auto-detection via pkg-config (rnnoise)." >&2
fi
./configure \
--enable-gpl --enable-nonfree \
--enable-libx264 --enable-libx265 --enable-libvpx --enable-libopus --enable-libmp3lame \
--enable-libvorbis --enable-libass --enable-fontconfig --enable-libfreetype \
--enable-librubberband --enable-libsoxr --enable-libzimg --enable-libvmaf \
--enable-libdav1d --enable-libaom --enable-libsvtav1 \
${RN_FLAG} \
--enable-ffplay --enable-ffprobe
make -j"$(nproc)"
echo "Build complete. You can run ./ffmpeg-build/FFmpeg/ffmpeg from this folder or 'sudo make install' to install system-wide."
set +x
else
echo "Skipped building from source."
fi
./configure \
--enable-gpl --enable-nonfree \
--enable-libx264 --enable-libx265 --enable-libvpx --enable-libopus --enable-libmp3lame \
--enable-libvorbis --enable-libass --enable-fontconfig --enable-libfreetype \
--enable-librubberband --enable-libsoxr --enable-libzimg --enable-libvmaf \
--enable-libdav1d --enable-libaom --enable-libsvtav1 \
${RN_FLAG} \
--enable-ffplay --enable-ffprobe
make -j"$(nproc)"
echo "Build complete. You can run ./ffmpeg-build/FFmpeg/ffmpeg from this folder or 'sudo make install' to install system-wide."
set +x
else
echo "Skipped building from source."
fi
}
main "$@"

View File

@ -4,8 +4,8 @@ set -euo pipefail
# Source common library for shared functions
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# shellcheck source=../../lib/common.sh
source "$SCRIPT_DIR/../../lib/common.sh"
# shellcheck source=../../../lib/common.sh
source "$SCRIPT_DIR/../../../lib/common.sh"
SCRIPT_NAME="$(basename "$0")"
@ -15,158 +15,158 @@ BLUE="\033[34m"
RESET="\033[0m"
info() {
printf "%b[%s]%b %s\n" "$BLUE" "$SCRIPT_NAME" "$RESET" "$*"
printf "%b[%s]%b %s\n" "$BLUE" "$SCRIPT_NAME" "$RESET" "$*"
}
warn() {
printf "%b[%s]%b %s\n" "$YELLOW" "$SCRIPT_NAME" "$RESET" "$*" >&2
printf "%b[%s]%b %s\n" "$YELLOW" "$SCRIPT_NAME" "$RESET" "$*" >&2
}
error() {
printf "%b[%s]%b %s\n" "$RED" "$SCRIPT_NAME" "$RESET" "$*" >&2
printf "%b[%s]%b %s\n" "$RED" "$SCRIPT_NAME" "$RESET" "$*" >&2
}
ensure_pacman_packages() {
install_missing_pacman_packages python git curl jq code
install_missing_pacman_packages python git curl jq code
}
install_uv() {
if command -v uv > /dev/null 2>&1; then
info "uv is already installed."
return
fi
if command -v uv >/dev/null 2>&1; then
info "uv is already installed."
return
fi
info "Installing uv toolchain manager via official installer."
curl -LsSf https://astral.sh/uv/install.sh | sh
info "Installing uv toolchain manager via official installer."
curl -LsSf https://astral.sh/uv/install.sh | sh
local local_bin="$HOME/.local/bin"
if [[ :$PATH: != *":$local_bin:"* ]]; then
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/.zshrc"
fi
local local_bin="$HOME/.local/bin"
if [[ :$PATH: != *":$local_bin:"* ]]; then
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/.zshrc"
fi
}
ensure_unity_hub() {
if command -v unityhub > /dev/null 2>&1; then
info "Unity Hub already installed."
return
fi
if command -v unityhub >/dev/null 2>&1; then
info "Unity Hub already installed."
return
fi
if command -v yay > /dev/null 2>&1; then
info "Installing Unity Hub from AUR using yay."
yay -S --needed --noconfirm unityhub
elif command -v flatpak > /dev/null 2>&1; then
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"
else
warn "Unity Hub not found and neither yay nor flatpak is available. Install Unity Hub manually from https://unity.com/download."
fi
if command -v yay >/dev/null 2>&1; then
info "Installing Unity Hub from AUR using yay."
yay -S --needed --noconfirm unityhub
elif command -v flatpak >/dev/null 2>&1; then
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"
else
warn "Unity Hub not found and neither yay nor flatpak is available. Install Unity Hub manually from https://unity.com/download."
fi
}
sync_unity_mcp_repo() {
local data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
local unity_mcp_root="$data_home/UnityMCP"
local repo_dir="$unity_mcp_root/unity-mcp-repo"
local server_link="$unity_mcp_root/UnityMcpServer"
local candidates=(
"UnityMcpServer"
"UnityMcpBridge/UnityMcpServer"
"UnityMcpBridge/UnityMcpServer~"
)
local server_subdir=""
local data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
local unity_mcp_root="$data_home/UnityMCP"
local repo_dir="$unity_mcp_root/unity-mcp-repo"
local server_link="$unity_mcp_root/UnityMcpServer"
local candidates=(
"UnityMcpServer"
"UnityMcpBridge/UnityMcpServer"
"UnityMcpBridge/UnityMcpServer~"
)
local server_subdir=""
mkdir -p "$unity_mcp_root"
mkdir -p "$unity_mcp_root"
if [[ -d "$repo_dir/.git" ]]; then
info "Updating existing unity-mcp repository."
git -C "$repo_dir" pull --ff-only
else
info "Cloning unity-mcp repository."
rm -rf "$repo_dir"
git clone --depth=1 https://github.com/CoplayDev/unity-mcp.git "$repo_dir"
fi
if [[ -d "$repo_dir/.git" ]]; then
info "Updating existing unity-mcp repository."
git -C "$repo_dir" pull --ff-only
else
info "Cloning unity-mcp repository."
rm -rf "$repo_dir"
git clone --depth=1 https://github.com/CoplayDev/unity-mcp.git "$repo_dir"
fi
for candidate in "${candidates[@]}"; do
if [[ -d "$repo_dir/$candidate/src" ]]; then
server_subdir="$candidate"
break
fi
done
for candidate in "${candidates[@]}"; do
if [[ -d "$repo_dir/$candidate/src" ]]; then
server_subdir="$candidate"
break
fi
done
if [[ -z $server_subdir ]]; then
error "UnityMcpServer src directory not found. Checked candidates: ${candidates[*]}"
error "Repository layout may have changed. Inspect $repo_dir for the new server location."
exit 1
fi
if [[ -z $server_subdir ]]; then
error "UnityMcpServer src directory not found. Checked candidates: ${candidates[*]}"
error "Repository layout may have changed. Inspect $repo_dir for the new server location."
exit 1
fi
ln -sfn "$repo_dir/$server_subdir" "$server_link"
info "UnityMcpServer synchronized at $server_link (source: $server_subdir)"
ln -sfn "$repo_dir/$server_subdir" "$server_link"
info "UnityMcpServer synchronized at $server_link (source: $server_subdir)"
}
configure_vscode_mcp() {
local data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
local server_src="$data_home/UnityMCP/UnityMcpServer/src"
local mcp_config_dir="$HOME/.config/Code/User"
local mcp_config="$mcp_config_dir/mcp.json"
local tmp
local data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
local server_src="$data_home/UnityMCP/UnityMcpServer/src"
local mcp_config_dir="$HOME/.config/Code/User"
local mcp_config="$mcp_config_dir/mcp.json"
local tmp
if [[ ! -d $server_src ]]; then
error "Server source directory $server_src is missing."
exit 1
fi
if [[ ! -d $server_src ]]; then
error "Server source directory $server_src is missing."
exit 1
fi
mkdir -p "$mcp_config_dir"
mkdir -p "$mcp_config_dir"
if [[ ! -f $mcp_config ]]; then
info "Creating new VS Code MCP configuration at $mcp_config"
echo '{}' > "$mcp_config"
else
info "Updating existing VS Code MCP configuration at $mcp_config"
fi
if [[ ! -f $mcp_config ]]; then
info "Creating new VS Code MCP configuration at $mcp_config"
echo '{}' >"$mcp_config"
else
info "Updating existing VS Code MCP configuration at $mcp_config"
fi
tmp="$(mktemp)"
tmp="$(mktemp)"
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."
exit 1
fi
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."
exit 1
fi
jq \
--arg path "$server_src" \
'(.servers //= {}) |
jq \
--arg path "$server_src" \
'(.servers //= {}) |
.servers.unityMCP = {
command: "uv",
args: ["--directory", $path, "run", "server.py"],
type: "stdio"
}' \
"$mcp_config" > "$tmp"
"$mcp_config" >"$tmp"
mv "$tmp" "$mcp_config"
info "VS Code MCP server configuration updated for UnityMCP."
mv "$tmp" "$mcp_config"
info "VS Code MCP server configuration updated for UnityMCP."
}
verify_python_version() {
require_command python "python"
local version
version="$(
python - << 'PY'
require_command python "python"
local version
version="$(
python - <<'PY'
import sys
print("%d.%d.%d" % sys.version_info[:3])
PY
)"
local major minor
IFS='.' read -r major minor _ <<< "$version"
if ((major < 3 || (major == 3 && minor < 12))); then
error "Python 3.12+ is required. Detected version $version. Upgrade python before continuing."
exit 1
fi
info "Python version $version satisfies requirement (>= 3.12)."
)"
local major minor
IFS='.' read -r major minor _ <<<"$version"
if ((major < 3 || (major == 3 && minor < 12))); then
error "Python 3.12+ is required. Detected version $version. Upgrade python before continuing."
exit 1
fi
info "Python version $version satisfies requirement (>= 3.12)."
}
print_next_steps() {
cat << 'EOT'
cat <<'EOT'
Next steps:
1. Launch Unity Hub and install a Unity Editor version 2021.3 LTS or newer.
@ -190,22 +190,22 @@ EOT
}
main() {
if [[ ! -f /etc/arch-release ]]; then
error "This script is intended for Arch Linux."
exit 1
fi
if [[ ! -f /etc/arch-release ]]; then
error "This script is intended for Arch Linux."
exit 1
fi
info "Ensuring base dependencies are installed."
require_command sudo "sudo"
require_command pacman "pacman"
ensure_pacman_packages
verify_python_version
install_uv
ensure_unity_hub
sync_unity_mcp_repo
configure_vscode_mcp
print_next_steps
info "Setup complete. Follow the next steps above to finish configuration inside Unity."
info "Ensuring base dependencies are installed."
require_command sudo "sudo"
require_command pacman "pacman"
ensure_pacman_packages
verify_python_version
install_uv
ensure_unity_hub
sync_unity_mcp_repo
configure_vscode_mcp
print_next_steps
info "Setup complete. Follow the next steps above to finish configuration inside Unity."
}
main "$@"

View File

@ -113,7 +113,7 @@ find_files() {
# Not a git repo - fall back to manual exclusion
local find_args=()
for i in "${!patterns[@]}"; do
if [ $i -eq 0 ]; then
if [ "$i" -eq 0 ]; then
find_args+=(-name "${patterns[$i]}")
else
find_args+=(-o -name "${patterns[$i]}")
@ -125,7 +125,7 @@ find_files() {
# No filtering - find all files
local find_args=()
for i in "${!patterns[@]}"; do
if [ $i -eq 0 ]; then
if [ "$i" -eq 0 ]; then
find_args+=(-name "${patterns[$i]}")
else
find_args+=(-o -name "${patterns[$i]}")
@ -302,7 +302,7 @@ if [ "$RESPECT_GITIGNORE" = true ] && is_git_repo; then
} | sort -u | wc -l)
echo "Files: $FILE_COUNT (respecting .gitignore)"
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
echo "Files: $(find . -type f | wc -l)"
fi
@ -336,7 +336,7 @@ print_header "STEP 4: Fast Keyword Analysis (Code vs Comments)"
fast_count() {
local top_n="${1:-50}"
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
python3 "$SCRIPT_DIR/fast_count.py" "$top_n"
fi
@ -387,14 +387,14 @@ HAS_GO=false
HAS_RUST=false
HAS_JAVA=false
((${LANG_FILES[c]} + ${LANG_FILES[cpp]} + ${LANG_FILES[h]} > 0)) && HAS_C_FAMILY=true
((${LANG_FILES[python]} > 0)) && HAS_PYTHON=true
((${LANG_FILES[javascript]} + ${LANG_FILES[typescript]} > 0)) && HAS_JS_FAMILY=true
((${LANG_FILES[shell]} > 0)) && HAS_SHELL=true
((${LANG_FILES[ruby]} > 0)) && HAS_RUBY=true
((${LANG_FILES[go]} > 0)) && HAS_GO=true
((${LANG_FILES[rust]} > 0)) && HAS_RUST=true
((${LANG_FILES[java]} > 0)) && HAS_JAVA=true
((LANG_FILES[c] + LANG_FILES[cpp] + LANG_FILES[h] > 0)) && HAS_C_FAMILY=true
((LANG_FILES[python] > 0)) && HAS_PYTHON=true
((LANG_FILES[javascript] + LANG_FILES[typescript] > 0)) && HAS_JS_FAMILY=true
((LANG_FILES[shell] > 0)) && HAS_SHELL=true
((LANG_FILES[ruby] > 0)) && HAS_RUBY=true
((LANG_FILES[go] > 0)) && HAS_GO=true
((LANG_FILES[rust] > 0)) && HAS_RUST=true
((LANG_FILES[java] > 0)) && HAS_JAVA=true
#------------------------------------------------------------------------------
# Language-specific keyword definitions
@ -735,7 +735,7 @@ if [ -f "$RESULTS_DIR/tags" ]; then
print_subheader "Symbol Types Distribution"
# 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
f) echo "$count functions" ;;
v) echo "$count variables" ;;

View File

@ -32,14 +32,14 @@ cd "$TRACKS_DIR"
# Tracks to download (add/remove as needed)
declare -A TRACKS=(
["python"]="https://github.com/exercism/python.git"
["c"]="https://github.com/exercism/c.git"
["cpp"]="https://github.com/exercism/cpp.git"
["javascript"]="https://github.com/exercism/javascript.git"
["typescript"]="https://github.com/exercism/typescript.git"
["rust"]="https://github.com/exercism/rust.git"
["go"]="https://github.com/exercism/go.git"
["bash"]="https://github.com/exercism/bash.git"
["python"]="https://github.com/exercism/python.git"
["c"]="https://github.com/exercism/c.git"
["cpp"]="https://github.com/exercism/cpp.git"
["javascript"]="https://github.com/exercism/javascript.git"
["typescript"]="https://github.com/exercism/typescript.git"
["rust"]="https://github.com/exercism/rust.git"
["go"]="https://github.com/exercism/go.git"
["bash"]="https://github.com/exercism/bash.git"
)
# Optional tracks (uncomment to include)
@ -52,22 +52,22 @@ echo "Downloading ${#TRACKS[@]} tracks to: $TRACKS_DIR"
echo ""
for track in "${!TRACKS[@]}"; do
url="${TRACKS[$track]}"
url="${TRACKS[$track]}"
if [[ -d $track ]]; then
info "Updating $track..."
(cd "$track" && git pull --quiet) && success "$track updated"
else
info "Cloning $track..."
git clone --depth 1 "$url" && success "$track cloned"
fi
if [[ -d $track ]]; then
info "Updating $track..."
(cd "$track" && git pull --quiet) && success "$track updated"
else
info "Cloning $track..."
git clone --depth 1 "$url" && success "$track cloned"
fi
# Show exercise count
if [[ -d "$track/exercises/practice" ]]; then
count=$(ls "$track/exercises/practice" | wc -l)
echo " → $count practice exercises available"
fi
echo ""
# Show exercise count
if [[ -d "$track/exercises/practice" ]]; then
count=$(find "$track/exercises/practice" -mindepth 1 -maxdepth 1 | wc -l)
echo " → $count practice exercises available"
fi
echo ""
done
echo "=============================================="
@ -99,8 +99,8 @@ echo "=============================================="
echo ""
echo "Track summary:"
for track in "${!TRACKS[@]}"; do
if [[ -d "$track/exercises/practice" ]]; then
count=$(ls "$track/exercises/practice" 2> /dev/null | wc -l)
printf " %-15s %3d exercises\n" "$track" "$count"
fi
if [[ -d "$track/exercises/practice" ]]; then
count=$(find "$track/exercises/practice" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l)
printf " %-15s %3d exercises\n" "$track" "$count"
fi
done | sort

View File

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

View File

@ -34,286 +34,297 @@ echo ""
# Install Exercism CLI
install_exercism_cli() {
if command -v exercism &> /dev/null; then
local version
version=$(exercism version 2> /dev/null | head -1)
success "Exercism CLI already installed: $version"
return 0
fi
if command -v exercism &>/dev/null; then
local version
version=$(exercism version 2>/dev/null | head -1)
success "Exercism CLI already installed: $version"
return 0
fi
echo "Installing Exercism CLI..."
echo "Installing Exercism CLI..."
# Try package managers first
if command -v pacman &> /dev/null; then
# Check AUR
if command -v yay &> /dev/null; then
yay -S --noconfirm exercism-bin
success "Exercism CLI installed via AUR"
return 0
elif command -v paru &> /dev/null; then
paru -S --noconfirm exercism-bin
success "Exercism CLI installed via AUR"
return 0
fi
elif command -v brew &> /dev/null; then
brew install exercism
success "Exercism CLI installed via Homebrew"
return 0
fi
# Try package managers first
if command -v pacman &>/dev/null; then
# Check AUR
if command -v yay &>/dev/null; then
yay -S --noconfirm exercism-bin
success "Exercism CLI installed via AUR"
return 0
elif command -v paru &>/dev/null; then
paru -S --noconfirm exercism-bin
success "Exercism CLI installed via AUR"
return 0
fi
elif command -v brew &>/dev/null; then
brew install exercism
success "Exercism CLI installed via Homebrew"
return 0
fi
# Manual installation from GitHub releases
info "Installing from GitHub releases..."
# Manual installation from GitHub releases
info "Installing from GitHub releases..."
local arch
case "$(uname -m)" in
x86_64) arch="x86_64" ;;
aarch64 | arm64) arch="arm64" ;;
armv7l) arch="armv7" ;;
i686) arch="i386" ;;
*)
error "Unsupported architecture: $(uname -m)"
return 1
;;
esac
local arch
case "$(uname -m)" in
x86_64) arch="x86_64" ;;
aarch64 | arm64) arch="arm64" ;;
armv7l) arch="armv7" ;;
i686) arch="i386" ;;
*)
error "Unsupported architecture: $(uname -m)"
return 1
;;
esac
local os="linux"
[[ "$(uname -s)" == "Darwin" ]] && os="darwin"
local os="linux"
[[ "$(uname -s)" == "Darwin" ]] && os="darwin"
# Get latest release
local latest_url="https://api.github.com/repos/exercism/cli/releases/latest"
local download_url
# Get latest release
local latest_url="https://api.github.com/repos/exercism/cli/releases/latest"
local download_url
download_url=$(curl -fsSL "$latest_url" | grep "browser_download_url.*${os}-${arch}" | head -1 | cut -d '"' -f 4)
download_url=$(curl -fsSL "$latest_url" | grep "browser_download_url.*${os}-${arch}" | head -1 | cut -d '"' -f 4)
if [[ -z $download_url ]]; then
error "Could not find download URL for your system"
echo "Please install manually from: https://exercism.org/docs/using/solving-exercises/working-locally"
return 1
fi
if [[ -z $download_url ]]; then
error "Could not find download URL for your system"
echo "Please install manually from: https://exercism.org/docs/using/solving-exercises/working-locally"
return 1
fi
echo "Downloading from: $download_url"
local temp_dir
temp_dir=$(mktemp -d)
echo "Downloading from: $download_url"
local temp_dir
temp_dir=$(mktemp -d)
curl -fL --progress-bar "$download_url" -o "$temp_dir/exercism.tar.gz"
tar -xzf "$temp_dir/exercism.tar.gz" -C "$temp_dir"
curl -fL --progress-bar "$download_url" -o "$temp_dir/exercism.tar.gz"
tar -xzf "$temp_dir/exercism.tar.gz" -C "$temp_dir"
# Install to ~/.local/bin
mkdir -p "$HOME/.local/bin"
mv "$temp_dir/exercism" "$HOME/.local/bin/"
chmod +x "$HOME/.local/bin/exercism"
# Install to ~/.local/bin
mkdir -p "$HOME/.local/bin"
mv "$temp_dir/exercism" "$HOME/.local/bin/"
chmod +x "$HOME/.local/bin/exercism"
rm -rf "$temp_dir"
rm -rf "$temp_dir"
success "Exercism CLI installed to ~/.local/bin/exercism"
success "Exercism CLI installed to ~/.local/bin/exercism"
# Check PATH
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
warn "Add ~/.local/bin to your PATH:"
echo ' export PATH="$HOME/.local/bin:$PATH"'
fi
# Check PATH
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
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"'
fi
}
# Configure exercism workspace
configure_exercism() {
echo ""
echo "=== Configuring Exercism ==="
echo ""
echo "=== Configuring Exercism ==="
mkdir -p "$EXERCISM_DIR"
mkdir -p "$EXERCISM_DIR"
# Check if already configured
if exercism configure 2>&1 | grep -q "workspace"; then
success "Exercism already configured"
else
# Set workspace directory
exercism configure --workspace="$EXERCISM_DIR"
success "Workspace set to: $EXERCISM_DIR"
fi
# Check if already configured
if exercism configure 2>&1 | grep -q "workspace"; then
success "Exercism already configured"
else
# Set workspace directory
exercism configure --workspace="$EXERCISM_DIR"
success "Workspace set to: $EXERCISM_DIR"
fi
echo ""
info "To fully configure Exercism with your account:"
echo " 1. Create free account at https://exercism.org"
echo " 2. Go to https://exercism.org/settings/api_cli"
echo " 3. Copy your API token"
echo " 4. Run: exercism configure --token=YOUR_TOKEN"
echo ""
echo ""
info "To fully configure Exercism with your account:"
echo " 1. Create free account at https://exercism.org"
echo " 2. Go to https://exercism.org/settings/api_cli"
echo " 3. Copy your API token"
echo " 4. Run: exercism configure --token=YOUR_TOKEN"
echo ""
}
# Install test runners for languages
install_test_runners() {
echo ""
echo "=== Installing Test Runners ==="
echo ""
echo ""
echo "=== Installing Test Runners ==="
echo ""
# Python - pytest
if command -v python3 &> /dev/null; then
if python3 -c "import pytest" 2> /dev/null; then
success "Python: pytest already installed"
else
info "Installing pytest for Python exercises..."
pip3 install --user pytest 2> /dev/null && success "Python: pytest installed" || warn "Python: install pytest manually"
fi
fi
# Python - pytest
if command -v python3 &>/dev/null; then
if python3 -c "import pytest" 2>/dev/null; then
success "Python: pytest already installed"
else
info "Installing pytest for Python exercises..."
if pip3 install --user pytest 2>/dev/null; then
success "Python: pytest installed"
else
warn "Python: install pytest manually"
fi
fi
fi
# JavaScript/TypeScript - Node.js + npm
if command -v node &> /dev/null; then
success "JavaScript/TypeScript: Node.js available ($(node --version))"
info " Tests run with: npm test (or jest)"
else
warn "JavaScript/TypeScript: Install Node.js for JS/TS exercises"
fi
# JavaScript/TypeScript - Node.js + npm
if command -v node &>/dev/null; then
success "JavaScript/TypeScript: Node.js available ($(node --version))"
info " Tests run with: npm test (or jest)"
else
warn "JavaScript/TypeScript: Install Node.js for JS/TS exercises"
fi
# C - gcc + criterion/cmocka
if command -v gcc &> /dev/null; then
success "C: gcc available"
info " Some C exercises use Unity test framework (included in exercise)"
else
warn "C: Install gcc for C exercises"
fi
# C - gcc + criterion/cmocka
if command -v gcc &>/dev/null; then
success "C: gcc available"
info " Some C exercises use Unity test framework (included in exercise)"
else
warn "C: Install gcc for C exercises"
fi
# C++ - g++ + Catch2/doctest
if command -v g++ &> /dev/null; then
success "C++: g++ available"
info " C++ exercises use Catch2 (header-only, included in exercise)"
else
warn "C++: Install g++ for C++ exercises"
fi
# C++ - g++ + Catch2/doctest
if command -v g++ &>/dev/null; then
success "C++: g++ available"
info " C++ exercises use Catch2 (header-only, included in exercise)"
else
warn "C++: Install g++ for C++ exercises"
fi
# Rust
if command -v cargo &> /dev/null; then
success "Rust: cargo available (tests with: cargo test)"
fi
# Rust
if command -v cargo &>/dev/null; then
success "Rust: cargo available (tests with: cargo test)"
fi
# Go
if command -v go &> /dev/null; then
success "Go: go available (tests with: go test)"
fi
# Go
if command -v go &>/dev/null; then
success "Go: go available (tests with: go test)"
fi
}
# Download exercises for a track (language)
download_track() {
local track="$1"
local count="${2:-10}"
local track="$1"
local count="${2:-10}"
echo ""
info "Downloading $count exercises for $track track..."
echo ""
info "Downloading $count exercises for $track track..."
# Get list of exercises
local exercises
exercises=$(curl -fsSL "https://exercism.org/api/v2/tracks/${track}/exercises" 2> /dev/null |
grep -oP '"slug":"\K[^"]+' | head -n "$count")
# Get list of exercises
local exercises
exercises=$(curl -fsSL "https://exercism.org/api/v2/tracks/${track}/exercises" 2>/dev/null |
grep -oP '"slug":"\K[^"]+' | head -n "$count")
if [[ -z $exercises ]]; then
warn "Could not fetch exercise list for $track"
return 1
fi
if [[ -z $exercises ]]; then
warn "Could not fetch exercise list for $track"
return 1
fi
local downloaded=0
for exercise in $exercises; do
local exercise_dir="$EXERCISM_DIR/$track/$exercise"
if [[ -d $exercise_dir ]]; then
echo " [exists] $exercise"
else
if exercism download --track="$track" --exercise="$exercise" 2> /dev/null; then
echo " [downloaded] $exercise"
((downloaded++))
else
echo " [failed] $exercise (may require auth)"
fi
fi
done
local downloaded=0
for exercise in $exercises; do
local exercise_dir="$EXERCISM_DIR/$track/$exercise"
if [[ -d $exercise_dir ]]; then
echo " [exists] $exercise"
else
if exercism download --track="$track" --exercise="$exercise" 2>/dev/null; then
echo " [downloaded] $exercise"
((downloaded++))
else
echo " [failed] $exercise (may require auth)"
fi
fi
done
success "Downloaded $downloaded new exercises for $track"
success "Downloaded $downloaded new exercises for $track"
}
# Show available tracks and usage
show_usage() {
echo ""
echo "=============================================="
echo " Exercism Usage Guide"
echo "=============================================="
echo ""
echo -e "${CYAN}Download exercises:${NC}"
echo " exercism download --track=python --exercise=hello-world"
echo " exercism download --track=javascript --exercise=two-fer"
echo " exercism download --track=c --exercise=isogram"
echo ""
echo -e "${CYAN}Run tests locally:${NC}"
echo " Python: cd ~/exercism/python/hello-world && pytest"
echo " JavaScript: cd ~/exercism/javascript/hello-world && npm test"
echo " TypeScript: cd ~/exercism/typescript/hello-world && npm test"
echo " C: cd ~/exercism/c/hello-world && make test"
echo " C++: cd ~/exercism/cpp/hello-world && make"
echo " Rust: cd ~/exercism/rust/hello-world && cargo test"
echo " Go: cd ~/exercism/go/hello-world && go test"
echo ""
echo -e "${CYAN}Submit solution (when online):${NC}"
echo " exercism submit solution.py"
echo ""
echo -e "${CYAN}Popular tracks:${NC}"
echo " python, javascript, typescript, c, cpp, rust, go, java, ruby"
echo " bash, elixir, haskell, kotlin, swift, csharp, php, sql"
echo ""
echo -e "${CYAN}Batch download (requires API token):${NC}"
echo " # Download first 20 Python exercises:"
echo ' for ex in $(exercism download --track=python 2>&1 | head -20); do'
echo ' exercism download --track=python --exercise=$ex'
echo " done"
echo ""
echo "Exercises are in: $EXERCISM_DIR"
echo ""
echo "=============================================="
echo ""
echo "=============================================="
echo " Exercism Usage Guide"
echo "=============================================="
echo ""
echo -e "${CYAN}Download exercises:${NC}"
echo " exercism download --track=python --exercise=hello-world"
echo " exercism download --track=javascript --exercise=two-fer"
echo " exercism download --track=c --exercise=isogram"
echo ""
echo -e "${CYAN}Run tests locally:${NC}"
echo " Python: cd ~/exercism/python/hello-world && pytest"
echo " JavaScript: cd ~/exercism/javascript/hello-world && npm test"
echo " TypeScript: cd ~/exercism/typescript/hello-world && npm test"
echo " C: cd ~/exercism/c/hello-world && make test"
echo " C++: cd ~/exercism/cpp/hello-world && make"
echo " Rust: cd ~/exercism/rust/hello-world && cargo test"
echo " Go: cd ~/exercism/go/hello-world && go test"
echo ""
echo -e "${CYAN}Submit solution (when online):${NC}"
echo " exercism submit solution.py"
echo ""
echo -e "${CYAN}Popular tracks:${NC}"
echo " python, javascript, typescript, c, cpp, rust, go, java, ruby"
echo " bash, elixir, haskell, kotlin, swift, csharp, php, sql"
echo ""
echo -e "${CYAN}Batch download (requires API token):${NC}"
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'
# shellcheck disable=SC2016
echo ' exercism download --track=python --exercise=$ex'
echo " done"
echo ""
echo "Exercises are in: $EXERCISM_DIR"
echo ""
echo "=============================================="
}
# Main
main() {
# Step 1: Install CLI
echo ""
echo "=== Step 1: Installing Exercism CLI ==="
install_exercism_cli
# Step 1: Install CLI
echo ""
echo "=== Step 1: Installing Exercism CLI ==="
install_exercism_cli
# Step 2: Configure
configure_exercism
# Step 2: Configure
configure_exercism
# Step 3: Install test runners
install_test_runners
# Step 3: Install test runners
install_test_runners
# Step 4: Download sample exercises
echo ""
echo "=== Step 4: Downloading Sample Exercises ==="
echo ""
echo "Downloading a few starter exercises for common languages..."
echo "(Full download requires API token from exercism.org)"
echo ""
# Step 4: Download sample exercises
echo ""
echo "=== Step 4: Downloading Sample Exercises ==="
echo ""
echo "Downloading a few starter exercises for common languages..."
echo "(Full download requires API token from exercism.org)"
echo ""
# Try to download hello-world for each track
local tracks=("python" "javascript" "typescript" "c" "cpp")
# Try to download hello-world for each track
local tracks=("python" "javascript" "typescript" "c" "cpp")
for track in "${tracks[@]}"; do
local exercise_dir="$EXERCISM_DIR/$track/hello-world"
if [[ -d $exercise_dir ]]; then
echo " [$track] hello-world already exists"
else
if exercism download --track="$track" --exercise="hello-world" 2> /dev/null; then
success "[$track] hello-world downloaded"
else
warn "[$track] hello-world requires authentication"
fi
fi
done
for track in "${tracks[@]}"; do
local exercise_dir="$EXERCISM_DIR/$track/hello-world"
if [[ -d $exercise_dir ]]; then
echo " [$track] hello-world already exists"
else
if exercism download --track="$track" --exercise="hello-world" 2>/dev/null; then
success "[$track] hello-world downloaded"
else
warn "[$track] hello-world requires authentication"
fi
fi
done
# Show usage
show_usage
# Show usage
show_usage
echo ""
success "Installation complete!"
echo ""
echo "Next steps:"
echo " 1. Sign up at https://exercism.org (free)"
echo " 2. Get your token from https://exercism.org/settings/api_cli"
echo " 3. Run: exercism configure --token=YOUR_TOKEN"
echo " 4. Download exercises and code offline!"
echo ""
echo ""
success "Installation complete!"
echo ""
echo "Next steps:"
echo " 1. Sign up at https://exercism.org (free)"
echo " 2. Get your token from https://exercism.org/settings/api_cli"
echo " 3. Run: exercism configure --token=YOUR_TOKEN"
echo " 4. Download exercises and code offline!"
echo ""
}
main "$@"

View File

@ -39,40 +39,43 @@ echo ""
echo "=== 1. Installing Python NLP-based Plagiarism Tools ==="
# Check for Python 3
if ! command -v python3 &> /dev/null; then
error "Python 3 is required but not installed."
exit 1
if ! command -v python3 &>/dev/null; then
error "Python 3 is required but not installed."
exit 1
fi
# Create virtual environment
if [ ! -d "$VENV_DIR" ]; then
echo "Creating Python virtual environment..."
python3 -m venv "$VENV_DIR"
success "Virtual environment created at $VENV_DIR"
echo "Creating Python virtual environment..."
python3 -m venv "$VENV_DIR"
success "Virtual environment created at $VENV_DIR"
else
warn "Virtual environment already exists at $VENV_DIR"
warn "Virtual environment already exists at $VENV_DIR"
fi
# 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"
echo "Installing Python packages for text similarity detection..."
pip install --upgrade pip
pip install --progress-bar on \
scikit-learn \
nltk \
spacy \
gensim \
numpy \
pandas \
python-docx \
PyPDF2 \
beautifulsoup4 \
lxml \
textdistance \
fuzzywuzzy \
python-Levenshtein
scikit-learn \
nltk \
spacy \
gensim \
numpy \
pandas \
python-docx \
PyPDF2 \
beautifulsoup4 \
lxml \
textdistance \
fuzzywuzzy \
python-Levenshtein
success "Python NLP packages installed"
@ -90,11 +93,11 @@ success "NLTK data downloaded"
# Download spaCy English model (small)
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"
# Create a simple plagiarism checker script
cat > "$INSTALL_DIR/check_plagiarism.py" << 'PYEOF'
cat >"$INSTALL_DIR/check_plagiarism.py" <<'PYEOF'
#!/usr/bin/env python3
"""
Simple Text Plagiarism Checker
@ -325,7 +328,7 @@ success "Created plagiarism checker script at $INSTALL_DIR/check_plagiarism.py"
# Create convenience wrapper
mkdir -p "$HOME/.local/bin"
cat > "$HOME/.local/bin/plagcheck" << WRAPEOF
cat >"$HOME/.local/bin/plagcheck" <<WRAPEOF
#!/usr/bin/env bash
# Wrapper for plagiarism checker
source "$VENV_DIR/bin/activate"
@ -344,14 +347,14 @@ echo "=== 2. Installing Sherlock Text Plagiarism Detector ==="
SHERLOCK_DIR="$INSTALL_DIR/sherlock"
if [ ! -d "$SHERLOCK_DIR" ]; then
# There are several Sherlock implementations; using a popular Python one
if command -v git &> /dev/null; then
# Clone a text-based similarity tool
git clone --depth 1 https://github.com/Zedeldi/sherlock-py.git "$SHERLOCK_DIR" 2> /dev/null || {
warn "Could not clone sherlock-py, trying alternative..."
# Alternative: Create a simple n-gram based sherlock
mkdir -p "$SHERLOCK_DIR"
cat > "$SHERLOCK_DIR/sherlock.py" << 'SHERLOCKEOF'
# There are several Sherlock implementations; using a popular Python one
if command -v git &>/dev/null; then
# Clone a text-based similarity tool
git clone --depth 1 https://github.com/Zedeldi/sherlock-py.git "$SHERLOCK_DIR" 2>/dev/null || {
warn "Could not clone sherlock-py, trying alternative..."
# Alternative: Create a simple n-gram based sherlock
mkdir -p "$SHERLOCK_DIR"
cat >"$SHERLOCK_DIR/sherlock.py" <<'SHERLOCKEOF'
#!/usr/bin/env python3
"""
Sherlock - Simple text plagiarism detector using n-gram fingerprinting.
@ -443,14 +446,14 @@ def main():
if __name__ == '__main__':
main()
SHERLOCKEOF
chmod +x "$SHERLOCK_DIR/sherlock.py"
}
success "Sherlock installed at $SHERLOCK_DIR"
else
warn "Git not available, skipping Sherlock installation"
fi
chmod +x "$SHERLOCK_DIR/sherlock.py"
}
success "Sherlock installed at $SHERLOCK_DIR"
else
warn "Git not available, skipping Sherlock installation"
fi
else
warn "Sherlock already installed at $SHERLOCK_DIR"
warn "Sherlock already installed at $SHERLOCK_DIR"
fi
# ------------------------------------------------------------------------------
@ -459,17 +462,17 @@ fi
echo ""
echo "=== 3. Checking for Ferret (Java-based plagiarism tool) ==="
if command -v java &> /dev/null; then
FERRET_DIR="$INSTALL_DIR/ferret"
if [ ! -d "$FERRET_DIR" ]; then
mkdir -p "$FERRET_DIR"
echo "Ferret is a Java-based tool from University of Hertfordshire."
echo "Download manually from: https://homepages.herts.ac.uk/~comqcln/Ferret/"
echo "Place JAR file in: $FERRET_DIR"
warn "Ferret requires manual download (academic license)"
fi
if command -v java &>/dev/null; then
FERRET_DIR="$INSTALL_DIR/ferret"
if [ ! -d "$FERRET_DIR" ]; then
mkdir -p "$FERRET_DIR"
echo "Ferret is a Java-based tool from University of Hertfordshire."
echo "Download manually from: https://homepages.herts.ac.uk/~comqcln/Ferret/"
echo "Place JAR file in: $FERRET_DIR"
warn "Ferret requires manual download (academic license)"
fi
else
warn "Java not installed, skipping Ferret"
warn "Java not installed, skipping Ferret"
fi
# ------------------------------------------------------------------------------
@ -478,16 +481,16 @@ fi
echo ""
echo "=== 4. WCopyfind Information (Windows tool, needs Wine) ==="
if command -v wine &> /dev/null; then
echo "Wine is available. WCopyfind can be run via Wine."
echo "Download from: https://plagiarism.bloomfieldmedia.com/software/wcopyfind/"
echo "Run with: wine /path/to/WCopyfind.exe"
warn "WCopyfind requires manual download"
if command -v wine &>/dev/null; then
echo "Wine is available. WCopyfind can be run via Wine."
echo "Download from: https://plagiarism.bloomfieldmedia.com/software/wcopyfind/"
echo "Run with: wine /path/to/WCopyfind.exe"
warn "WCopyfind requires manual download"
else
echo "Wine not installed. To use WCopyfind:"
echo " 1. Install wine: sudo apt install wine (or equivalent)"
echo " 2. Download WCopyfind from: https://plagiarism.bloomfieldmedia.com/software/wcopyfind/"
warn "WCopyfind skipped (Wine not available)"
echo "Wine not installed. To use WCopyfind:"
echo " 1. Install wine: sudo apt install wine (or equivalent)"
echo " 2. Download WCopyfind from: https://plagiarism.bloomfieldmedia.com/software/wcopyfind/"
warn "WCopyfind skipped (Wine not available)"
fi
# ------------------------------------------------------------------------------
@ -521,12 +524,18 @@ echo " plagcheck thesis_v1.pdf thesis_v2.pdf --detailed"
echo " plagcheck --dir ./student_papers/ --threshold 0.4"
echo ""
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 ""
echo "=============================================="
# Add to PATH reminder
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
warn "Add ~/.local/bin to your PATH by adding this to ~/.bashrc or ~/.zshrc:"
echo ' export PATH="$HOME/.local/bin:$PATH"'
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"'
fi

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,11 @@ ensure_adb_installed() {
if command -v pacman &>/dev/null; then
sudo pacman -S --noconfirm android-tools || die "Failed to install android-tools"
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
sudo dnf install -y android-tools || die "Failed to install android-tools"
else

View File

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

View File

@ -8,22 +8,22 @@ REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd)
TARGET_SCRIPT="$REPO_DIR/scripts/periodic_background/utils/android_guardian/service.sh"
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
assert_equals() {
local expected="$1"
local actual="$2"
local context="$3"
if [[ "$expected" != "$actual" ]]; then
fail "$context (expected: '$expected', actual: '$actual')"
fi
local expected="$1"
local actual="$2"
local context="$3"
if [[ "$expected" != "$actual" ]]; then
fail "$context (expected: '$expected', actual: '$actual')"
fi
}
TMP_DIR=$(mktemp -d)
cleanup() {
rm -rf "$TMP_DIR"
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
@ -34,12 +34,12 @@ cp "$TARGET_SCRIPT" "$WORKTREE/scripts/periodic_background/utils/android_guardia
printf 'Checking skip-main avoids boot side effects...\n'
SKIP_MAIN_GUARDIAN_DIR="$TMP_DIR/skip-main-guardian"
ANDROID_GUARDIAN_SKIP_MAIN=1 \
ANDROID_GUARDIAN_DIR="$SKIP_MAIN_GUARDIAN_DIR" \
ANDROID_GUARDIAN_MODULE_DIR="$TMP_DIR/skip-main-module" \
sh "$TARGET_SCRIPT"
ANDROID_GUARDIAN_DIR="$SKIP_MAIN_GUARDIAN_DIR" \
ANDROID_GUARDIAN_MODULE_DIR="$TMP_DIR/skip-main-module" \
sh "$TARGET_SCRIPT"
[[ ! -d "$SKIP_MAIN_GUARDIAN_DIR" ]] \
|| fail 'skip-main should prevent guardian boot setup side effects'
[[ ! -d "$SKIP_MAIN_GUARDIAN_DIR" ]] ||
fail 'skip-main should prevent guardian boot setup side effects'
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)
@ -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'
printf 'Checking guardian loop protects module every tick...\n'
grep -q 'protect_module' "$WORKTREE/scripts/periodic_background/utils/android_guardian/service.sh" \
|| fail 'guardian loop must always protect the module each tick'
grep -q 'protect_module' "$WORKTREE/scripts/periodic_background/utils/android_guardian/service.sh" ||
fail 'guardian loop must always protect the module each tick'
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" \
|| fail 'guardian service should cache installed packages once per blocked-app scan'
# Literal search pattern: the $ must reach grep as text, not be expanded
# 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
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
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" \
|| fail 'guardian service should support skip-main testing'
grep -q 'ANDROID_GUARDIAN_SKIP_MAIN' "$WORKTREE/scripts/periodic_background/utils/android_guardian/service.sh" ||
fail 'guardian service should support skip-main testing'
printf 'android_guardian service regression checks passed.\n'

View File

@ -60,7 +60,7 @@ assert_order() {
exit 1
fi
if (( first_line < second_line )); then
if ((first_line < second_line)); then
echo "PASS: $message"
else
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' \
"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/' \
"installer deploys shared hosts guard hook helpers"

View File

@ -13,209 +13,212 @@ echo ""
# Test 1: Check wrapper syntax
echo "[TEST 1] Checking wrapper script syntax..."
if bash -n "$WRAPPER_DIR/pacman_wrapper.sh"; then
echo "✓ Wrapper script syntax is valid"
echo "✓ Wrapper script syntax is valid"
else
echo "✗ Wrapper script has syntax errors"
exit 1
echo "✗ Wrapper script has syntax errors"
exit 1
fi
# Test 2: Check installer syntax
echo "[TEST 2] Checking installer script syntax..."
if bash -n "$WRAPPER_DIR/install_pacman_wrapper.sh"; then
echo "✓ Installer script syntax is valid"
echo "✓ Installer script syntax is valid"
else
echo "✗ Installer script has syntax errors"
exit 1
echo "✗ Installer script has syntax errors"
exit 1
fi
# Test 3: Check VirtualBox enforcement script syntax
echo "[TEST 3] Checking VirtualBox enforcement script syntax..."
if bash -n "$VBOX_DIR/enforce_vbox_hosts.sh"; then
echo "✓ VirtualBox enforcement script syntax is valid"
echo "✓ VirtualBox enforcement script syntax is valid"
else
echo "✗ VirtualBox enforcement script has syntax errors"
exit 1
echo "✗ VirtualBox enforcement script has syntax errors"
exit 1
fi
# Test 4: Verify integrity check function exists
echo "[TEST 4] Verifying integrity check function exists in wrapper..."
if grep -q "verify_policy_integrity()" "$WRAPPER_DIR/pacman_wrapper.sh"; then
echo "✓ Integrity verification function found"
echo "✓ Integrity verification function found"
else
echo "✗ Integrity verification function not found"
exit 1
echo "✗ Integrity verification function not found"
exit 1
fi
# Test 5: Verify hardcoded VirtualBox cleanup function exists
echo "[TEST 5] Verifying hardcoded VirtualBox cleanup function exists..."
if grep -q "auto_remove_virtualbox_vms()" "$WRAPPER_DIR/pacman_wrapper.sh"; then
echo "✓ Hardcoded VirtualBox cleanup function found"
echo "✓ Hardcoded VirtualBox cleanup function found"
else
echo "✗ Hardcoded VirtualBox cleanup function not found"
exit 1
echo "✗ Hardcoded VirtualBox cleanup function not found"
exit 1
fi
# Test 6: Verify VirtualBox cleanup uses VBoxManage directly
echo "[TEST 6] Verifying VirtualBox cleanup uses VBoxManage directly..."
if grep -q "VBoxManage" "$WRAPPER_DIR/pacman_wrapper.sh"; then
echo "✓ VirtualBox cleanup logic found"
echo "✓ VirtualBox cleanup logic found"
else
echo "✗ VirtualBox cleanup logic not found"
exit 1
echo "✗ VirtualBox cleanup logic not found"
exit 1
fi
# Test 7: Verify integrity check is called early in execution
echo "[TEST 7] Verifying integrity check is called before operations..."
if grep -B 2 -A 2 "verify_policy_integrity" "$WRAPPER_DIR/pacman_wrapper.sh" | grep -q "CRITICAL"; then
echo "✓ Integrity check is called early in execution"
echo "✓ Integrity check is called early in execution"
else
echo "✗ Integrity check not found in early execution"
exit 1
echo "✗ Integrity check not found in early execution"
exit 1
fi
# Test 8: Verify installer creates integrity file
echo "[TEST 8] Verifying installer creates integrity checksums..."
if grep -q "INTEGRITY_FILE" "$WRAPPER_DIR/install_pacman_wrapper.sh"; then
echo "✓ Installer references integrity file"
echo "✓ Installer references integrity file"
else
echo "✗ Installer does not create integrity file"
exit 1
echo "✗ Installer does not create integrity file"
exit 1
fi
# Test 9: Verify installer uses chattr to make files immutable
echo "[TEST 9] Verifying installer makes policy files immutable..."
if grep -q "chattr +i" "$WRAPPER_DIR/install_pacman_wrapper.sh"; then
echo "✓ Installer sets immutable attributes"
echo "✓ Installer sets immutable attributes"
else
echo "✗ Installer does not set immutable attributes"
exit 1
echo "✗ Installer does not set immutable attributes"
exit 1
fi
# Test 10: Verify VirtualBox cleanup enforcement is integrated
echo "[TEST 10] Verifying VirtualBox cleanup is integrated into wrapper..."
if grep -q "auto_remove_virtualbox_vms" "$WRAPPER_DIR/pacman_wrapper.sh"; then
echo "✓ VirtualBox cleanup integration found"
echo "✓ VirtualBox cleanup integration found"
else
echo "✗ VirtualBox cleanup integration not found"
exit 1
echo "✗ VirtualBox cleanup integration not found"
exit 1
fi
# Test 11: Verify VirtualBox script can show help
echo "[TEST 11] Testing VirtualBox enforcement script help..."
# Run without invoking sudo by setting EUID check (or just check for the help text in the file)
if grep -q "VirtualBox /etc/hosts Enforcement Tool" "$VBOX_DIR/enforce_vbox_hosts.sh"; then
echo "✓ VirtualBox enforcement script has help text"
echo "✓ VirtualBox enforcement script has help text"
else
echo "✗ VirtualBox enforcement script help text not found"
exit 1
echo "✗ VirtualBox enforcement script help text not found"
exit 1
fi
# Test 12: Verify installer installs VirtualBox enforcement script
echo "[TEST 12] Verifying installer handles VirtualBox enforcement script..."
if grep -q "VBOX_ENFORCE" "$WRAPPER_DIR/install_pacman_wrapper.sh"; then
echo "✓ Installer includes VirtualBox enforcement script"
echo "✓ Installer includes VirtualBox enforcement script"
else
echo "✗ Installer does not include VirtualBox enforcement script"
exit 1
echo "✗ Installer does not include VirtualBox enforcement script"
exit 1
fi
# Test 13: Verify makepkg capped wrapper script syntax
echo "[TEST 13] Checking makepkg capped wrapper syntax..."
if bash -n "$WRAPPER_DIR/makepkg_capped.sh"; then
echo "✓ makepkg capped wrapper syntax is valid"
echo "✓ makepkg capped wrapper syntax is valid"
else
echo "✗ makepkg capped wrapper has syntax errors"
exit 1
echo "✗ makepkg capped wrapper has syntax errors"
exit 1
fi
# Test 14: Verify pacman wrapper exposes makepkg capped command
echo "[TEST 14] Verifying pacman wrapper supports --makepkg-capped..."
if grep -q -- "--makepkg-capped" "$WRAPPER_DIR/pacman_wrapper.sh"; then
echo "✓ pacman wrapper makepkg capped command found"
echo "✓ pacman wrapper makepkg capped command found"
else
echo "✗ pacman wrapper makepkg capped command missing"
exit 1
echo "✗ pacman wrapper makepkg capped command missing"
exit 1
fi
# Test 15: Verify installer deploys makepkg capped wrapper
echo "[TEST 15] Verifying installer deploys makepkg capped wrapper..."
if grep -q "MAKEPKG_CAPPED" "$WRAPPER_DIR/install_pacman_wrapper.sh"; then
echo "✓ Installer includes makepkg capped deployment"
echo "✓ Installer includes makepkg capped deployment"
else
echo "✗ Installer does not include makepkg capped deployment"
exit 1
echo "✗ Installer does not include makepkg capped deployment"
exit 1
fi
# Test 16: Verify mkpkg helper script syntax
echo "[TEST 16] Checking mkpkg helper script syntax..."
if bash -n "$WRAPPER_DIR/mkpkg.sh"; then
echo "✓ mkpkg helper script syntax is valid"
echo "✓ mkpkg helper script syntax is valid"
else
echo "✗ mkpkg helper script has syntax errors"
exit 1
echo "✗ mkpkg helper script has syntax errors"
exit 1
fi
# Test 17: Verify installer deploys mkpkg helper
echo "[TEST 17] Verifying installer deploys mkpkg helper..."
if grep -q "MKPKG" "$WRAPPER_DIR/install_pacman_wrapper.sh"; then
echo "✓ Installer includes mkpkg helper deployment"
echo "✓ Installer includes mkpkg helper deployment"
else
echo "✗ Installer does not include mkpkg helper deployment"
exit 1
echo "✗ Installer does not include mkpkg helper deployment"
exit 1
fi
# Test 18: Verify installer runs in strict mode
echo "[TEST 18] Verifying installer uses strict shell mode..."
if grep -q "set -euo pipefail" "$WRAPPER_DIR/install_pacman_wrapper.sh"; then
echo "✓ Installer strict mode enabled"
echo "✓ Installer strict mode enabled"
else
echo "✗ Installer strict mode not enabled"
exit 1
echo "✗ Installer strict mode not enabled"
exit 1
fi
# Test 19: Verify installer handles immutable files during updates
echo "[TEST 19] Verifying installer unlocks immutable files before copy/write..."
if grep -q "unlock_immutable_file_if_needed" "$WRAPPER_DIR/install_pacman_wrapper.sh"; then
echo "✓ Installer immutable-file handling found"
echo "✓ Installer immutable-file handling found"
else
echo "✗ Installer immutable-file handling missing"
exit 1
echo "✗ Installer immutable-file handling missing"
exit 1
fi
# Test: shared lock library syntax
echo "[TEST] Checking shared lock library syntax..."
if bash -n "$WRAPPER_DIR/pacman_lock_lib.sh"; then
echo "✓ pacman_lock_lib.sh syntax is valid"
echo "✓ pacman_lock_lib.sh syntax is valid"
else
echo "✗ pacman_lock_lib.sh has syntax errors"
exit 1
echo "✗ pacman_lock_lib.sh has syntax errors"
exit 1
fi
# Test: wrapper sources the shared lock library
echo "[TEST] Verifying wrapper sources the shared lock library..."
if grep -q 'source .*pacman_lock_lib.sh' "$WRAPPER_DIR/pacman_wrapper.sh"; then
echo "✓ Wrapper sources pacman_lock_lib.sh"
echo "✓ Wrapper sources pacman_lock_lib.sh"
else
echo "✗ Wrapper does not source pacman_lock_lib.sh"
exit 1
echo "✗ Wrapper does not source pacman_lock_lib.sh"
exit 1
fi
# Test: the lock library is covered by the integrity manifest
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
echo "✓ Lock library is included in the integrity manifest"
echo "✓ Lock library is included in the integrity manifest"
else
echo "✗ Lock library not checksummed by installer"
exit 1
echo "✗ Lock library not checksummed by installer"
exit 1
fi
# Test: lib is sourced AFTER integrity verification (tamper caught before source)
echo "[TEST] Verifying lock library is sourced after integrity check..."
if awk '/verify_policy_integrity/{v=NR} /source .*pacman_lock_lib.sh/{s=NR} END{exit !(v && s && s>v)}' "$WRAPPER_DIR/pacman_wrapper.sh"; then
echo "✓ Lock library sourced after verify_policy_integrity"
echo "✓ Lock library sourced after verify_policy_integrity"
else
echo "✗ Lock library not sourced after integrity verification"
exit 1
echo "✗ Lock library not sourced after integrity verification"
exit 1
fi
echo ""

View File

@ -9,22 +9,22 @@ TARGET_SCRIPT="$REPO_DIR/scripts/periodic_background/system-maintenance/bin/shut
SETUP_SCRIPT="$REPO_DIR/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh"
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
assert_equals() {
local expected="$1"
local actual="$2"
local context="$3"
if [[ "$expected" != "$actual" ]]; then
fail "$context (expected: '$expected', actual: '$actual')"
fi
local expected="$1"
local actual="$2"
local context="$3"
if [[ "$expected" != "$actual" ]]; then
fail "$context (expected: '$expected', actual: '$actual')"
fi
}
TMP_DIR=$(mktemp -d)
cleanup() {
rm -rf "$TMP_DIR"
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
@ -81,42 +81,46 @@ EOF
chmod +x "$BIN_DIR/tee"
run_case() {
local expected_mode="$1"
local busctl_present="$2"
local sleep_log="$TMP_DIR/sleep.log"
local mode_file="$TMP_DIR/mode.log"
local expected_mode="$1"
local busctl_present="$2"
local sleep_log="$TMP_DIR/sleep.log"
local mode_file="$TMP_DIR/mode.log"
: >"$sleep_log"
: >"$mode_file"
if [[ $busctl_present -eq 0 ]]; then
mv "$BIN_DIR/busctl" "$BIN_DIR/busctl.off"
fi
: >"$sleep_log"
: >"$mode_file"
if [[ $busctl_present -eq 0 ]]; then
mv "$BIN_DIR/busctl" "$BIN_DIR/busctl.off"
fi
mode=$(env -i PATH="$BIN_DIR" SLEEP_LOG="$sleep_log" SHUTDOWN_TIMER_MONITOR_SKIP_MAIN=1 /bin/bash -c \
"source '$WORKTREE/scripts/system-maintenance/bin/shutdown-timer-monitor.sh'; \
mode=$(env -i PATH="$BIN_DIR" SLEEP_LOG="$sleep_log" SHUTDOWN_TIMER_MONITOR_SKIP_MAIN=1 /bin/bash -c \
"source '$WORKTREE/scripts/system-maintenance/bin/shutdown-timer-monitor.sh'; \
timer_needs_restoration() { return 1; }; \
restore_timer() { :; }; \
monitor_with_dbus() { printf 'dbus'; }; \
monitor_with_polling() { printf 'polling'; }; \
start_monitoring")
assert_equals "$expected_mode" "$mode" 'shutdown timer monitor should choose the expected dispatcher'
assert_equals "$expected_mode" "$mode" 'shutdown timer monitor should choose the expected dispatcher'
if [[ -f "$BIN_DIR/busctl.off" ]]; then
mv "$BIN_DIR/busctl.off" "$BIN_DIR/busctl"
fi
if [[ -f "$BIN_DIR/busctl.off" ]]; then
mv "$BIN_DIR/busctl.off" "$BIN_DIR/busctl"
fi
}
run_dbus_throttle_case() {
local ts_sequence="$1"
local expected_calls="$2"
local check_interval="${3:-30}"
local calls
local counter_file="$TMP_DIR/timer_checks.log"
local ts_sequence="$1"
local expected_calls="$2"
local check_interval="${3:-30}"
local calls
local counter_file="$TMP_DIR/timer_checks.log"
: >"$counter_file"
: >"$counter_file"
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 '
# 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 '
source "$1"
CHECK_INTERVAL="$TEST_CHECK_INTERVAL"
timer_needs_restoration() { printf "x\n" >> "$COUNTER_FILE"; return 1; }
@ -142,7 +146,7 @@ run_dbus_throttle_case() {
printf "%s" "$timer_checks"
' _ "$WORKTREE/scripts/system-maintenance/bin/shutdown-timer-monitor.sh")
assert_equals "$expected_calls" "$calls" 'monitor_with_dbus should throttle repeated relevant events'
assert_equals "$expected_calls" "$calls" 'monitor_with_dbus should throttle repeated relevant events'
}
printf 'Checking D-Bus path is preferred when busctl exists...\n'
@ -159,7 +163,7 @@ run_dbus_throttle_case '100 101 102' '3' '0'
printf 'Checking wait helper enforces delay even with /dev/null stdin...\n'
wait_elapsed=$(env -i PATH="/usr/bin:/bin" SHUTDOWN_TIMER_MONITOR_SKIP_MAIN=1 /bin/bash -c \
"source '$WORKTREE/scripts/system-maintenance/bin/shutdown-timer-monitor.sh'; \
"source '$WORKTREE/scripts/system-maintenance/bin/shutdown-timer-monitor.sh'; \
start=\$(printf '%(%s)T' -1); \
wait_seconds 1; \
end=\$(printf '%(%s)T' -1); \
@ -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'
printf 'Checking installer template stays in sync with the event-driven monitor...\n'
grep -Fq 'monitor_with_dbus()' "$SETUP_SCRIPT" \
|| fail 'setup_midnight_shutdown.sh should install the D-Bus monitor helper'
grep -Fq 'start_monitoring()' "$SETUP_SCRIPT" \
|| fail 'setup_midnight_shutdown.sh should install the start_monitoring dispatcher'
grep -Fq 'if command -v busctl &>/dev/null; then' "$SETUP_SCRIPT" \
|| fail 'setup_midnight_shutdown.sh should prefer busctl when available'
grep -Fq 'current_epoch now_ts' "$SETUP_SCRIPT" \
|| fail 'setup_midnight_shutdown.sh should use out-var epoch helper in D-Bus throttling path'
grep -Fq 'monitor_with_dbus()' "$SETUP_SCRIPT" ||
fail 'setup_midnight_shutdown.sh should install the D-Bus monitor helper'
grep -Fq 'start_monitoring()' "$SETUP_SCRIPT" ||
fail 'setup_midnight_shutdown.sh should install the start_monitoring dispatcher'
grep -Fq 'if command -v busctl &>/dev/null; then' "$SETUP_SCRIPT" ||
fail 'setup_midnight_shutdown.sh should prefer busctl when available'
grep -Fq 'current_epoch now_ts' "$SETUP_SCRIPT" ||
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
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
# Literal source text, as above.
# shellcheck disable=SC2016
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
grep -Fq 'OnUnitActiveSec=300' "$SETUP_SCRIPT" \
|| fail 'setup_midnight_shutdown.sh should run watchdog timer at 300s cadence'
grep -Fq 'wait_seconds()' "$SETUP_SCRIPT" \
|| fail 'setup_midnight_shutdown.sh should install builtin wait helper in polling fallback'
grep -Fq 'wait_seconds "$CHECK_INTERVAL"' "$TARGET_SCRIPT" \
|| fail 'runtime shutdown monitor polling fallback should use builtin wait helper'
grep -Fq 'OnUnitActiveSec=300' "$SETUP_SCRIPT" ||
fail 'setup_midnight_shutdown.sh should run watchdog timer at 300s cadence'
grep -Fq 'wait_seconds()' "$SETUP_SCRIPT" ||
fail 'setup_midnight_shutdown.sh should install builtin wait helper in polling fallback'
# Literal source text, as above.
# 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'

View File

@ -8,12 +8,12 @@ REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd)
INSTALLER="$REPO_DIR/scripts/periodic_background/system-maintenance/bin/install_usage_monitoring.sh"
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
logger_template=$(
awk '
awk '
/cat > "\$HOME\/.local\/bin\/nvidia-pmon-logger\.sh" << '\''SCRIPT'\''/ {capture=1; next}
capture && /^SCRIPT$/ {capture=0; exit}
capture {print}
@ -23,30 +23,36 @@ logger_template=$(
[[ -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'
! grep -q 'read -r -t' <<< "$logger_template" \
|| fail 'logger template must not use read -t as sleep surrogate'
! grep -q 'read -r -t' <<<"$logger_template" ||
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
# sleeps exactly once, until the next midnight, and blocks on `wait` for the
# pmon child — strictly fewer wakeups. Assert that property rather than the
# old literal, so an accidental return to polling still fails the test.
printf 'Checking pmon logger template sleeps until rollover rather than polling...\n'
grep -q 'sleep "\$(seconds_until_next_day)"' <<< "$logger_template" \
|| fail 'logger template must sleep until the next day boundary'
# Literal search pattern: the $ must reach grep as text, not be expanded
# 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" \
|| fail 'logger template must define seconds_until_next_day'
grep -q 'seconds_until_next_day()' <<<"$logger_template" ||
fail 'logger template must define seconds_until_next_day'
printf 'Checking pmon logger template blocks on wait instead of spinning...\n'
grep -qE '^\s*wait "\$pmon_pid"' <<< "$logger_template" \
|| fail 'logger template must block on wait for the pmon child'
# Literal search pattern: the $ must reach grep as text, not be expanded
# 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'
grep -q "printf '%(%Y%m%d)T' -1" <<< "$logger_template" \
|| fail 'logger template must use bash printf time builtin for current day'
grep -q "printf '%(%Y%m%d)T' -1" <<<"$logger_template" ||
fail 'logger template must use bash printf time builtin for current day'
printf 'Checking pmon logger template avoids external date command...\n'
! grep -q 'date +%Y%m%d' <<< "$logger_template" \
|| fail 'logger template must not call external date command in hot path'
! grep -q 'date +%Y%m%d' <<<"$logger_template" ||
fail 'logger template must not call external date command in hot path'
printf 'Usage monitoring installer efficiency tests passed.\n'

View File

@ -26,78 +26,85 @@ BOLD='\033[1m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="${SCRIPT_DIR}"
PYTHON_PATHS=(
"PYTHON"
"articles"
"poker-modifier-app"
"tests"
"PYTHON"
"articles"
"poker-modifier-app"
"tests"
)
EXCLUDE_PATHS=(
".venv"
"__pycache__"
".git"
"Bash/ffmpeg-build"
".pytest_cache"
".ruff_cache"
".mypy_cache"
".venv"
"__pycache__"
".git"
"Bash/ffmpeg-build"
".pytest_cache"
".ruff_cache"
".mypy_cache"
)
# Build exclude pattern for find
EXCLUDE_PATTERN=""
for path in "${EXCLUDE_PATHS[@]}"; do
EXCLUDE_PATTERN="${EXCLUDE_PATTERN} -path '*/${path}/*' -prune -o"
EXCLUDE_PATTERN="${EXCLUDE_PATTERN} -path '*/${path}/*' -prune -o"
done
# Parse arguments
FIX_MODE=false
QUICK_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_ARR=()
while [[ $# -gt 0 ]]; do
case $1 in
--fix|-f)
FIX_MODE=true
shift
;;
--quick|-q)
QUICK_MODE=true
shift
;;
--report|-r)
REPORT_MODE=true
shift
;;
--help|-h)
echo "Usage: $0 [OPTIONS] [FILES...]"
echo ""
echo "Options:"
echo " --fix, -f Auto-fix issues where possible"
echo " --quick, -q Quick mode (ruff + mypy only)"
echo " --report, -r Generate detailed reports to ./lint-reports/"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " $0 # Lint all Python files"
echo " $0 --fix # Lint and auto-fix"
echo " $0 PYTHON/ # Lint specific directory"
echo " $0 --quick --fix # Quick lint with auto-fix"
exit 0
;;
*)
TARGET_FILES="${TARGET_FILES} $1"
shift
;;
esac
case $1 in
--fix | -f)
FIX_MODE=true
shift
;;
--quick | -q)
QUICK_MODE=true
shift
;;
--report | -r)
REPORT_MODE=true
shift
;;
--help | -h)
echo "Usage: $0 [OPTIONS] [FILES...]"
echo ""
echo "Options:"
echo " --fix, -f Auto-fix issues where possible"
echo " --quick, -q Quick mode (ruff + mypy only)"
echo " --report, -r Generate detailed reports to ./lint-reports/"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " $0 # Lint all Python files"
echo " $0 --fix # Lint and auto-fix"
echo " $0 PYTHON/ # Lint specific directory"
echo " $0 --quick --fix # Quick lint with auto-fix"
exit 0
;;
*)
TARGET_FILES="${TARGET_FILES} $1"
TARGET_FILES_ARR+=("$1")
shift
;;
esac
done
# If no target specified, use default paths
if [[ -z "${TARGET_FILES}" ]]; then
TARGET_FILES="${PYTHON_PATHS[*]}"
TARGET_FILES="${PYTHON_PATHS[*]}"
TARGET_FILES_ARR=("${PYTHON_PATHS[@]}")
fi
# Create reports directory if needed
if [[ "${REPORT_MODE}" == true ]]; then
mkdir -p "${PROJECT_ROOT}/lint-reports"
mkdir -p "${PROJECT_ROOT}/lint-reports"
fi
# Track overall status
@ -109,70 +116,70 @@ FAILED_TOOLS=()
# ==============================================================================
print_header() {
echo ""
echo -e "${BOLD}${BLUE}══════════════════════════════════════════════════════════════${NC}"
echo -e "${BOLD}${BLUE} $1${NC}"
echo -e "${BOLD}${BLUE}══════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e "${BOLD}${BLUE}══════════════════════════════════════════════════════════════${NC}"
echo -e "${BOLD}${BLUE} $1${NC}"
echo -e "${BOLD}${BLUE}══════════════════════════════════════════════════════════════${NC}"
}
print_subheader() {
echo ""
echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}"
echo -e "${CYAN} $1${NC}"
echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}"
echo ""
echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}"
echo -e "${CYAN} $1${NC}"
echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}"
}
print_success() {
echo -e "${GREEN}✓${NC} $1"
echo -e "${GREEN}✓${NC} $1"
}
print_warning() {
echo -e "${YELLOW}⚠${NC} $1"
echo -e "${YELLOW}⚠${NC} $1"
}
print_error() {
echo -e "${RED}✗${NC} $1"
echo -e "${RED}✗${NC} $1"
}
print_info() {
echo -e "${BLUE}ℹ${NC} $1"
echo -e "${BLUE}ℹ${NC} $1"
}
run_tool() {
local tool_name="$1"
local tool_cmd="$2"
local report_file="${PROJECT_ROOT}/lint-reports/${tool_name}.txt"
local tool_name="$1"
local tool_cmd="$2"
local report_file="${PROJECT_ROOT}/lint-reports/${tool_name}.txt"
print_subheader "Running ${tool_name}..."
print_subheader "Running ${tool_name}..."
if [[ "${REPORT_MODE}" == true ]]; then
if eval "${tool_cmd}" 2>&1 | tee "${report_file}"; then
print_success "${tool_name} passed"
return 0
else
print_error "${tool_name} found issues (see ${report_file})"
FAILED_TOOLS+=("${tool_name}")
return 1
fi
else
if eval "${tool_cmd}"; then
print_success "${tool_name} passed"
return 0
else
print_error "${tool_name} found issues"
FAILED_TOOLS+=("${tool_name}")
return 1
fi
fi
if [[ "${REPORT_MODE}" == true ]]; then
if eval "${tool_cmd}" 2>&1 | tee "${report_file}"; then
print_success "${tool_name} passed"
return 0
else
print_error "${tool_name} found issues (see ${report_file})"
FAILED_TOOLS+=("${tool_name}")
return 1
fi
else
if eval "${tool_cmd}"; then
print_success "${tool_name} passed"
return 0
else
print_error "${tool_name} found issues"
FAILED_TOOLS+=("${tool_name}")
return 1
fi
fi
}
check_tool() {
if command -v "$1" &> /dev/null; then
return 0
else
print_warning "$1 not found, skipping..."
return 1
fi
if command -v "$1" &>/dev/null; then
return 0
else
print_warning "$1 not found, skipping..."
return 1
fi
}
# ==============================================================================
@ -192,135 +199,135 @@ cd "${PROJECT_ROOT}"
# RUFF - Primary linter and formatter
# ==============================================================================
if check_tool ruff; then
if [[ "${FIX_MODE}" == true ]]; then
run_tool "ruff-lint" "ruff check --fix --show-fixes ${TARGET_FILES}" || OVERALL_STATUS=1
run_tool "ruff-format" "ruff format ${TARGET_FILES}" || OVERALL_STATUS=1
else
run_tool "ruff-lint" "ruff check ${TARGET_FILES}" || OVERALL_STATUS=1
run_tool "ruff-format-check" "ruff format --check ${TARGET_FILES}" || OVERALL_STATUS=1
fi
if [[ "${FIX_MODE}" == true ]]; then
run_tool "ruff-lint" "ruff check --fix --show-fixes ${TARGET_FILES}" || OVERALL_STATUS=1
run_tool "ruff-format" "ruff format ${TARGET_FILES}" || OVERALL_STATUS=1
else
run_tool "ruff-lint" "ruff check ${TARGET_FILES}" || OVERALL_STATUS=1
run_tool "ruff-format-check" "ruff format --check ${TARGET_FILES}" || OVERALL_STATUS=1
fi
fi
# ==============================================================================
# MYPY - Static type checking
# ==============================================================================
if check_tool mypy; then
run_tool "mypy" "mypy --strict --ignore-missing-imports ${TARGET_FILES}" || OVERALL_STATUS=1
run_tool "mypy" "mypy --strict --ignore-missing-imports ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# Quick mode exits here
if [[ "${QUICK_MODE}" == true ]]; then
print_header "Quick Lint Complete"
if [[ ${#FAILED_TOOLS[@]} -gt 0 ]]; then
print_error "Failed tools: ${FAILED_TOOLS[*]}"
exit 1
else
print_success "All quick checks passed!"
exit 0
fi
print_header "Quick Lint Complete"
if [[ ${#FAILED_TOOLS[@]} -gt 0 ]]; then
print_error "Failed tools: ${FAILED_TOOLS[*]}"
exit 1
else
print_success "All quick checks passed!"
exit 0
fi
fi
# ==============================================================================
# PYLINT - Comprehensive linting
# ==============================================================================
if check_tool pylint; then
run_tool "pylint" "pylint --rcfile=pyproject.toml --jobs=0 --fail-under=10 ${TARGET_FILES}" || OVERALL_STATUS=1
run_tool "pylint" "pylint --rcfile=pyproject.toml --jobs=0 --fail-under=10 ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# BANDIT - Security linting
# ==============================================================================
if check_tool bandit; then
run_tool "bandit" "bandit -c pyproject.toml -r ${TARGET_FILES} --severity-level low --confidence-level low" || OVERALL_STATUS=1
run_tool "bandit" "bandit -c pyproject.toml -r ${TARGET_FILES} --severity-level low --confidence-level low" || OVERALL_STATUS=1
fi
# ==============================================================================
# VULTURE - Dead code detection
# ==============================================================================
if check_tool vulture; then
run_tool "vulture" "vulture --min-confidence 80 ${TARGET_FILES}" || OVERALL_STATUS=1
run_tool "vulture" "vulture --min-confidence 80 ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# FLAKE8 - Traditional linter
# ==============================================================================
if check_tool flake8; then
run_tool "flake8" "flake8 --max-line-length=88 --extend-ignore=E203,W503 --max-complexity=10 ${TARGET_FILES}" || OVERALL_STATUS=1
run_tool "flake8" "flake8 --max-line-length=88 --extend-ignore=E203,W503 --max-complexity=10 ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# PYCODESTYLE - PEP 8 style checker
# ==============================================================================
if check_tool pycodestyle; then
run_tool "pycodestyle" "pycodestyle --max-line-length=88 --ignore=E203,W503 ${TARGET_FILES}" || OVERALL_STATUS=1
run_tool "pycodestyle" "pycodestyle --max-line-length=88 --ignore=E203,W503 ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# PYDOCSTYLE - Docstring style checker
# ==============================================================================
if check_tool pydocstyle; then
run_tool "pydocstyle" "pydocstyle --convention=google ${TARGET_FILES}" || OVERALL_STATUS=1
run_tool "pydocstyle" "pydocstyle --convention=google ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# RADON - Complexity metrics
# ==============================================================================
if check_tool radon; then
print_subheader "Running radon (complexity analysis)..."
echo ""
echo -e "${MAGENTA}Cyclomatic Complexity:${NC}"
radon cc -a -s ${TARGET_FILES} || true
echo ""
echo -e "${MAGENTA}Maintainability Index:${NC}"
radon mi -s ${TARGET_FILES} || true
print_subheader "Running radon (complexity analysis)..."
echo ""
echo -e "${MAGENTA}Cyclomatic Complexity:${NC}"
radon cc -a -s "${TARGET_FILES_ARR[@]}" || true
echo ""
echo -e "${MAGENTA}Maintainability Index:${NC}"
radon mi -s "${TARGET_FILES_ARR[@]}" || true
if [[ "${REPORT_MODE}" == true ]]; then
radon cc -a -s ${TARGET_FILES} > "${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
fi
if [[ "${REPORT_MODE}" == true ]]; then
radon cc -a -s "${TARGET_FILES_ARR[@]}" >"${PROJECT_ROOT}/lint-reports/radon-cc.txt" 2>&1 || true
radon mi -s "${TARGET_FILES_ARR[@]}" >"${PROJECT_ROOT}/lint-reports/radon-mi.txt" 2>&1 || true
fi
fi
# ==============================================================================
# INTERROGATE - Docstring coverage
# ==============================================================================
if check_tool interrogate; then
run_tool "interrogate" "interrogate -v --fail-under=0 ${TARGET_FILES}" || OVERALL_STATUS=1
run_tool "interrogate" "interrogate -v --fail-under=0 ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# PYRIGHT - Microsoft's type checker (optional, very strict)
# ==============================================================================
if check_tool pyright; then
run_tool "pyright" "pyright ${TARGET_FILES}" || OVERALL_STATUS=1
run_tool "pyright" "pyright ${TARGET_FILES}" || OVERALL_STATUS=1
fi
# ==============================================================================
# AUTOFLAKE - Unused imports/variables (fix mode only)
# ==============================================================================
if [[ "${FIX_MODE}" == true ]] && check_tool autoflake; then
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 {} \;
print_success "autoflake completed"
print_subheader "Running autoflake (removing unused imports)..."
find "${TARGET_FILES_ARR[@]}" -name "*.py" -type f -exec autoflake --in-place --remove-all-unused-imports --remove-unused-variables {} \;
print_success "autoflake completed"
fi
# ==============================================================================
# PYUPGRADE - Upgrade Python syntax (fix mode only)
# ==============================================================================
if [[ "${FIX_MODE}" == true ]] && check_tool pyupgrade; then
print_subheader "Running pyupgrade (upgrading syntax to Python 3.10+)..."
find ${TARGET_FILES} -name "*.py" -type f -exec pyupgrade --py310-plus {} \;
print_success "pyupgrade completed"
print_subheader "Running pyupgrade (upgrading syntax to Python 3.10+)..."
find "${TARGET_FILES_ARR[@]}" -name "*.py" -type f -exec pyupgrade --py310-plus {} \;
print_success "pyupgrade completed"
fi
# ==============================================================================
# CODESPELL - Spell checking
# ==============================================================================
if check_tool codespell; then
if [[ "${FIX_MODE}" == true ]]; then
run_tool "codespell" "codespell -w --skip='*.json,*.lock,.git,__pycache__,.venv' ${TARGET_FILES}" || OVERALL_STATUS=1
else
run_tool "codespell" "codespell --skip='*.json,*.lock,.git,__pycache__,.venv' ${TARGET_FILES}" || OVERALL_STATUS=1
fi
if [[ "${FIX_MODE}" == true ]]; then
run_tool "codespell" "codespell -w --skip='*.json,*.lock,.git,__pycache__,.venv' ${TARGET_FILES}" || OVERALL_STATUS=1
else
run_tool "codespell" "codespell --skip='*.json,*.lock,.git,__pycache__,.venv' ${TARGET_FILES}" || OVERALL_STATUS=1
fi
fi
# ==============================================================================
@ -330,17 +337,17 @@ print_header "Linting Summary"
echo ""
if [[ ${OVERALL_STATUS} -ne 0 ]]; then
print_error "The following tools reported issues:"
for tool in "${FAILED_TOOLS[@]}"; do
echo " - ${tool}"
done
echo ""
if [[ "${REPORT_MODE}" == true ]]; then
print_info "Detailed reports saved to: ${PROJECT_ROOT}/lint-reports/"
fi
print_info "Run with --fix to auto-fix issues where possible"
exit ${OVERALL_STATUS}
print_error "The following tools reported issues:"
for tool in "${FAILED_TOOLS[@]}"; do
echo " - ${tool}"
done
echo ""
if [[ "${REPORT_MODE}" == true ]]; then
print_info "Detailed reports saved to: ${PROJECT_ROOT}/lint-reports/"
fi
print_info "Run with --fix to auto-fix issues where possible"
exit ${OVERALL_STATUS}
else
print_success "All linting checks passed!"
exit 0
print_success "All linting checks passed!"
exit 0
fi

View File

@ -17,131 +17,134 @@ REPORT_SCRIPT="$SCRIPT_DIR/linux_configuration/scripts/periodic_background/syste
ARTIFACT_INIT_SCRIPT="$SCRIPT_DIR/meta/scripts/init_agent_artifacts.sh"
if [[ ! -f "$REPORT_SCRIPT" ]]; then
echo "Error: usage_report.py not found at: $REPORT_SCRIPT" >&2
exit 1
echo "Error: usage_report.py not found at: $REPORT_SCRIPT" >&2
exit 1
fi
if [[ ! -f "$ARTIFACT_INIT_SCRIPT" ]]; then
echo "Error: init_agent_artifacts.sh not found at: $ARTIFACT_INIT_SCRIPT" >&2
exit 1
echo "Error: init_agent_artifacts.sh not found at: $ARTIFACT_INIT_SCRIPT" >&2
exit 1
fi
# Profiling mode: trace fork-heavy scripts over time
profile_polling_scripts() {
local duration="${1:-60}"
echo "=== Polling Script Profiler (${duration}s) ===" >&2
echo "Tracing fork/exec calls in shell scripts..." >&2
echo "" >&2
local duration="${1:-60}"
echo "=== Polling Script Profiler (${duration}s) ===" >&2
echo "Tracing fork/exec calls in shell scripts..." >&2
echo "" >&2
# Find common polling script processes and trace them
local trace_file="/tmp/polling_trace_$$.txt"
# Find common polling script processes and trace them
local trace_file="/tmp/polling_trace_$$.txt"
# Use perf/strace to capture system calls
(
timeout "$duration" strace -f -e trace=clone,execve -c -p $$ 2>&1 || true
) > "$trace_file" 2>&1
# Use perf/strace to capture system calls
(
timeout "$duration" strace -f -e trace=clone,execve -c -p $$ 2>&1 || true
) >"$trace_file" 2>&1
echo "Trace completed. Analyzing results:" >&2
echo "" >&2
echo "Trace completed. Analyzing results:" >&2
echo "" >&2
# Show fork/exec heavy processes
if ! grep -e "execve" -e "clone" "$trace_file" | head -20; then
:
fi
# Show fork/exec heavy processes
if ! grep -e "execve" -e "clone" "$trace_file" | head -20; then
:
fi
rm -f "$trace_file"
rm -f "$trace_file"
}
# Diagnostic mode: find inefficient patterns in shell scripts
diagnose_polling_scripts() {
echo "=== Shell Script Efficiency Audit ===" >&2
echo "" >&2
echo "=== Shell Script Efficiency Audit ===" >&2
echo "" >&2
local issues_found=0
local issues_found=0
# Check for common anti-patterns
echo "Checking for anti-patterns in shell scripts..." >&2
echo "" >&2
# Check for common anti-patterns
echo "Checking for anti-patterns in shell scripts..." >&2
echo "" >&2
# Pattern 1: while true with sleep (no event-driven check)
echo "1. Polling loops (while true + sleep):" >&2
set +e
grep -r "while true\|while :" --include="*.sh" "$SCRIPT_DIR" 2>/dev/null \
| grep -v "Binary" | grep -v ".git" | head -5
set -e
issues_found=$((issues_found + 1))
echo "" >&2
# Pattern 1: while true with sleep (no event-driven check)
echo "1. Polling loops (while true + sleep):" >&2
set +e
grep -r "while true\|while :" --include="*.sh" "$SCRIPT_DIR" 2>/dev/null |
grep -v "Binary" | grep -v ".git" | head -5
set -e
issues_found=$((issues_found + 1))
echo "" >&2
# Pattern 2: $(date +...) calls in loops (fork-heavy)
echo "2. Excessive date calls (each forks a process):" >&2
set +e
grep -r '\$(date' --include="*.sh" "$SCRIPT_DIR" 2>/dev/null \
| grep -v "Binary" | grep -v ".git" | head -5
set -e
issues_found=$((issues_found + 1))
echo "" >&2
# Pattern 2: $(date +...) calls in loops (fork-heavy)
echo "2. Excessive date calls (each forks a process):" >&2
set +e
# Literal search pattern: the $ must reach grep as text, not be expanded
# 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
issues_found=$((issues_found + 1))
echo "" >&2
# Pattern 3: pgrep/xdotool in loops
echo "3. Process inspection in loops (pgrep, xdotool):" >&2
set +e
grep -r "while.*pgrep\|while.*xdotool\|pgrep.*while" --include="*.sh" "$SCRIPT_DIR" 2>/dev/null \
| grep -v "Binary" | grep -v ".git" | head -5
set -e
issues_found=$((issues_found + 1))
echo "" >&2
# Pattern 3: pgrep/xdotool in loops
echo "3. Process inspection in loops (pgrep, xdotool):" >&2
set +e
grep -r "while.*pgrep\|while.*xdotool\|pgrep.*while" --include="*.sh" "$SCRIPT_DIR" 2>/dev/null |
grep -v "Binary" | grep -v ".git" | head -5
set -e
issues_found=$((issues_found + 1))
echo "" >&2
# Pattern 4: pipes in hot paths
echo "4. Heavy pipes in polling scripts (| awk, | grep, | tr):" >&2
set +e
while_true_file_list="$(mktemp)"
heavy_pipe_matches="$(mktemp)"
grep -r "while true" --include="*.sh" "$SCRIPT_DIR" > "$while_true_file_list" 2>/dev/null
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
head -5 "$heavy_pipe_matches"
fi
rm -f "$while_true_file_list" "$heavy_pipe_matches"
set -e
issues_found=$((issues_found + 1))
echo "" >&2
# Pattern 4: pipes in hot paths
echo "4. Heavy pipes in polling scripts (| awk, | grep, | tr):" >&2
set +e
while_true_file_list="$(mktemp)"
heavy_pipe_matches="$(mktemp)"
grep -r "while true" --include="*.sh" "$SCRIPT_DIR" >"$while_true_file_list" 2>/dev/null
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
head -5 "$heavy_pipe_matches"
fi
rm -f "$while_true_file_list" "$heavy_pipe_matches"
set -e
issues_found=$((issues_found + 1))
echo "" >&2
# Pattern 5: sleep with very short intervals
echo "5. Aggressive polling (sleep < 1s):" >&2
set +e
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
set -e
issues_found=$((issues_found + 1))
echo "" >&2
# Pattern 5: sleep with very short intervals
echo "5. Aggressive polling (sleep < 1s):" >&2
set +e
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
set -e
issues_found=$((issues_found + 1))
echo "" >&2
echo "=== Recommendations ===" >&2
echo "1. Replace 'while true + sleep' with event-driven I/O (inotifywait, read -t, etc.)" >&2
echo "2. Use /proc and /sys instead of forking date, sensors, acpi, etc." >&2
echo "3. Cache frequently accessed values (e.g., in /tmp state files)" >&2
echo "4. Use bash builtins: printf %()T instead of date, \${var//} instead of tr, etc." >&2
echo "5. Use i3blocks interval=persist + event loop instead of polling mode" >&2
echo "6. Increase polling intervals: 1s → 5s → 10s where acceptable" >&2
echo "=== Recommendations ===" >&2
echo "1. Replace 'while true + sleep' with event-driven I/O (inotifywait, read -t, etc.)" >&2
echo "2. Use /proc and /sys instead of forking date, sensors, acpi, etc." >&2
echo "3. Cache frequently accessed values (e.g., in /tmp state files)" >&2
echo "4. Use bash builtins: printf %()T instead of date, \${var//} instead of tr, etc." >&2
echo "5. Use i3blocks interval=persist + event loop instead of polling mode" >&2
echo "6. Increase polling intervals: 1s → 5s → 10s where acceptable" >&2
}
# Handle special modes
case "${1:-}" in
--profile)
profile_polling_scripts "${2:-60}"
exit 0
;;
--diagnose)
diagnose_polling_scripts
exit 0
;;
--init-artifacts)
shift
exec "$ARTIFACT_INIT_SCRIPT" "$@"
;;
--help)
grep '^# Usage:' "$0" | sed 's/^# //' | head -1
grep '^# ' "$0" | sed 's/^# / /'
exit 0
;;
--profile)
profile_polling_scripts "${2:-60}"
exit 0
;;
--diagnose)
diagnose_polling_scripts
exit 0
;;
--init-artifacts)
shift
exec "$ARTIFACT_INIT_SCRIPT" "$@"
;;
--help)
grep '^# Usage:' "$0" | sed 's/^# //' | head -1
grep '^# ' "$0" | sed 's/^# / /'
exit 0
;;
esac
# Default: run usage_report.py with all remaining args

View File

@ -11,48 +11,50 @@ errors=0
files_checked=0
# 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=(
['while_true_with_sleep']='while\s+(true|:).*sleep'
['date_in_loop']='while.*date|for.*date'
['pgrep_in_loop']='while.*pgrep|for.*pgrep'
['xdotool_in_loop']='while.*xdotool|for.*xdotool'
['pipe_fork_chain']='|.*|.*|.*|'
['subshell_date']='$\(date\s+\+'
['backtick_date']='`date\s+\+'
['excessive_fork_chain']='(\$\(.*\|.*\|.*\)|`.*\|.*\|`)'
['sleep_less_than_one']='sleep\s+0\.[0-9]'
['while_true_with_sleep']='while\s+(true|:).*sleep'
['date_in_loop']='while.*date|for.*date'
['pgrep_in_loop']='while.*pgrep|for.*pgrep'
['xdotool_in_loop']='while.*xdotool|for.*xdotool'
['pipe_fork_chain']='|.*|.*|.*|'
['subshell_date']='$\(date\s+\+'
['backtick_date']='`date\s+\+'
['excessive_fork_chain']='(\$\(.*\|.*\|.*\)|`.*\|.*\|`)'
['sleep_less_than_one']='sleep\s+0\.[0-9]'
)
usage() {
echo "Usage: $(basename "$0") [files...]"
echo ""
echo "Checks shell scripts for polling anti-patterns that cause fork storms."
echo "Exit code 0 = no issues, 1 = issues found"
echo ""
echo "Patterns detected:"
echo " - while true + sleep (should use event-driven I/O)"
echo " - \$(date +...) in loops (forks process)"
echo " - pgrep/xdotool in loops (forks process)"
echo " - Multiple piped commands (each | forks)"
echo " - Aggressive polling (sleep < 1s)"
exit 0
echo "Usage: $(basename "$0") [files...]"
echo ""
echo "Checks shell scripts for polling anti-patterns that cause fork storms."
echo "Exit code 0 = no issues, 1 = issues found"
echo ""
echo "Patterns detected:"
echo " - while true + sleep (should use event-driven I/O)"
echo " - \$(date +...) in loops (forks process)"
echo " - pgrep/xdotool in loops (forks process)"
echo " - Multiple piped commands (each | forks)"
echo " - Aggressive polling (sleep < 1s)"
exit 0
}
check_file() {
local file="$1"
local file="$1"
# Skip non-shell files
if ! grep -qE '^#!.*/(ba)?sh' "$file" 2>/dev/null; then
return 0
fi
# Skip non-shell files
if ! grep -qE '^#!.*/(ba)?sh' "$file" 2>/dev/null; then
return 0
fi
files_checked=$((files_checked + 1))
files_checked=$((files_checked + 1))
# Single-pass awk analysis: C code, no bash loops, no per-line subshell forks.
# '\'' embeds a literal single quote inside a bash single-quoted string.
local findings
findings=$(awk '
# Single-pass awk analysis: C code, no bash loops, no per-line subshell forks.
# '\'' embeds a literal single quote inside a bash single-quoted string.
local findings
findings=$(awk '
/^[[:space:]]*([a-zA-Z0-9_]*_(loop|daemon)|poll[a-zA-Z0-9_]*)[[:space:]]*\(\)/ { in_poll=1 }
/^[[:space:]]*\}/ { in_poll=0 }
in_poll {
@ -77,58 +79,58 @@ check_file() {
}
' "$file")
if [[ -n "$findings" ]]; then
echo " $file:"
while IFS= read -r finding; do
echo " Line $finding"
done <<< "$findings"
errors=$((errors + 1))
return 1
fi
return 0
if [[ -n "$findings" ]]; then
echo " $file:"
while IFS= read -r finding; do
echo " Line $finding"
done <<<"$findings"
errors=$((errors + 1))
return 1
fi
return 0
}
provide_suggestions() {
echo ""
echo "📋 Optimization Tips:"
echo ""
echo "Replace polling loops with:"
echo " • inotifywait/fanotify for file system events"
echo " • timerfd for interval-based tasks"
echo " • select/poll for I/O multiplexing"
echo " • systemd timers for scheduled tasks"
echo " • dbus signals for system events"
echo ""
echo "Reduce fork overhead:"
echo " • Cache \$(date +%s) in variable, update periodically"
echo " • Use /proc filesystem instead of pgrep"
echo " • Consolidate commands with && instead of separate invocations"
echo ""
echo ""
echo "📋 Optimization Tips:"
echo ""
echo "Replace polling loops with:"
echo " • inotifywait/fanotify for file system events"
echo " • timerfd for interval-based tasks"
echo " • select/poll for I/O multiplexing"
echo " • systemd timers for scheduled tasks"
echo " • dbus signals for system events"
echo ""
echo "Reduce fork overhead:"
echo " • Cache \$(date +%s) in variable, update periodically"
echo " • Use /proc filesystem instead of pgrep"
echo " • Consolidate commands with && instead of separate invocations"
echo ""
}
main() {
# Show usage if requested
[[ "$#" -eq 0 || "$1" == "-h" || "$1" == "--help" ]] && usage
# Show usage if requested
[[ "$#" -eq 0 || "$1" == "-h" || "$1" == "--help" ]] && usage
# Check all provided files
for file in "$@"; do
check_file "$file" || true
done
# Check all provided files
for file in "$@"; do
check_file "$file" || true
done
# Report results
if [[ $files_checked -eq 0 ]]; then
echo "ℹ️ No shell scripts to check"
exit 0
fi
# Report results
if [[ $files_checked -eq 0 ]]; then
echo "ℹ️ No shell scripts to check"
exit 0
fi
if [[ $errors -gt 0 ]]; then
echo ""
echo "❌ Found $errors issue(s) in shell scripts"
provide_suggestions
exit 1
else
echo "✓ No polling anti-patterns detected in $files_checked file(s)"
exit 0
fi
if [[ $errors -gt 0 ]]; then
echo ""
echo "❌ Found $errors issue(s) in shell scripts"
provide_suggestions
exit 1
else
echo "✓ No polling anti-patterns detected in $files_checked file(s)"
exit 0
fi
}
main "$@"

View File

@ -7,16 +7,16 @@
#
# Safe by default: nothing is deleted without --clean AND user confirmation.
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
RESET='\033[0m'
RED=$'\033[0;31m'
GREEN=$'\033[0;32m'
YELLOW=$'\033[1;33m'
CYAN=$'\033[0;36m'
BOLD=$'\033[1m'
RESET=$'\033[0m'
CLEAN=false
if [[ "${1:-}" == "--clean" ]]; then
CLEAN=true
CLEAN=true
fi
TOTAL_RECLAIMABLE=0
@ -24,66 +24,66 @@ TOTAL_RECLAIMABLE=0
# ───────────────────── helpers ─────────────────────
human_readable() {
local kb=$1
if (( kb >= 1048576 )); then
printf "%.1f GB" "$(echo "scale=1; $kb / 1048576" | bc)"
elif (( kb >= 1024 )); then
printf "%.1f MB" "$(echo "scale=1; $kb / 1024" | bc)"
else
printf "%d KB" "$kb"
fi
local kb=$1
if ((kb >= 1048576)); then
printf "%.1f GB" "$(echo "scale=1; $kb / 1048576" | bc)"
elif ((kb >= 1024)); then
printf "%.1f MB" "$(echo "scale=1; $kb / 1024" | bc)"
else
printf "%d KB" "$kb"
fi
}
dir_size_kb() {
local dir="$1"
if [[ -d "$dir" ]]; then
du -sk "$dir" 2>/dev/null | awk '{print $1}'
else
echo 0
fi
local dir="$1"
if [[ -d "$dir" ]]; then
du -sk "$dir" 2>/dev/null | awk '{print $1}'
else
echo 0
fi
}
report() {
local label="$1" size_kb="$2" detail="${3:-}"
if (( size_kb > 0 )); then
local hr
hr=$(human_readable "$size_kb")
printf "${YELLOW}%-40s${RESET} %10s" "$label" "$hr"
if [[ -n "$detail" ]]; then
printf " ${CYAN}(%s)${RESET}" "$detail"
fi
printf "\n"
TOTAL_RECLAIMABLE=$(( TOTAL_RECLAIMABLE + size_kb ))
fi
local label="$1" size_kb="$2" detail="${3:-}"
if ((size_kb > 0)); then
local hr
hr=$(human_readable "$size_kb")
printf "${YELLOW}%-40s${RESET} %10s" "$label" "$hr"
if [[ -n "$detail" ]]; then
printf " ${CYAN}(%s)${RESET}" "$detail"
fi
printf "\n"
TOTAL_RECLAIMABLE=$((TOTAL_RECLAIMABLE + size_kb))
fi
}
# Returns 0 if user confirms, 1 otherwise. Always 1 in dry-run.
confirm() {
local prompt="$1"
if $CLEAN; then
printf "${BOLD} → %s [y/N]: ${RESET}" "$prompt"
read -r ans
if [[ "$ans" =~ ^[Yy]$ ]]; then
return 0
fi
fi
return 1
local prompt="$1"
if $CLEAN; then
printf "${BOLD} → %s [y/N]: ${RESET}" "$prompt"
read -r ans
if [[ "$ans" =~ ^[Yy]$ ]]; then
return 0
fi
fi
return 1
}
# Safe wrapper: confirm + action, never fails under errexit
try_clean() {
local prompt="$1"
shift
if confirm "$prompt"; then
"$@"
printf "${GREEN} ✓ Done${RESET}\n"
fi
return 0
local prompt="$1"
shift
if confirm "$prompt"; then
"$@"
printf "%s ✓ Done%s\n" "${GREEN}" "${RESET}"
fi
return 0
}
# ───────────────────── 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 '%s\n' "$(printf '%.0s─' {1..60})"
@ -91,48 +91,48 @@ printf '%s\n' "$(printf '%.0s─' {1..60})"
trash_dir="$HOME/.local/share/Trash"
size=$(dir_size_kb "$trash_dir")
report "Trash" "$size" "empty trash"
if (( size > 0 )); then
try_clean "Empty trash?" \
rm -rf "${trash_dir}/files" "${trash_dir}/info" "${trash_dir}/expunged"
if ((size > 0)); then
try_clean "Empty trash?" \
rm -rf "${trash_dir}/files" "${trash_dir}/info" "${trash_dir}/expunged"
fi
# 2. Pacman cache
pacman_cache_size=$(dir_size_kb "/var/cache/pacman/pkg")
if (( pacman_cache_size > 0 )); then
if command -v paccache &>/dev/null; then
pkg_count=$(ls /var/cache/pacman/pkg/ 2>/dev/null | wc -l)
report "Pacman cache ($pkg_count pkgs)" "$pacman_cache_size" \
"sudo paccache -rk2"
try_clean "Clean pacman cache (keep last 2 versions)?" \
sudo paccache -rk2
else
report "Pacman cache (install pacman-contrib)" "$pacman_cache_size" \
"pacman -S pacman-contrib && paccache -rk2"
fi
if ((pacman_cache_size > 0)); then
if command -v paccache &>/dev/null; then
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" \
"sudo paccache -rk2"
try_clean "Clean pacman cache (keep last 2 versions)?" \
sudo paccache -rk2
else
report "Pacman cache (install pacman-contrib)" "$pacman_cache_size" \
"pacman -S pacman-contrib && paccache -rk2"
fi
fi
# 3. Yay build cache
yay_cache="$HOME/.cache/yay"
size=$(dir_size_kb "$yay_cache")
report "Yay AUR build cache" "$size" "rm -rf ~/.cache/yay"
if (( size > 0 )); then
try_clean "Clear yay build cache?" rm -rf "${yay_cache:?}"
if ((size > 0)); then
try_clean "Clear yay build cache?" rm -rf "${yay_cache:?}"
fi
# 4. Pip cache
pip_cache="$HOME/.cache/pip"
size=$(dir_size_kb "$pip_cache")
report "Pip cache" "$size" "pip cache purge"
if (( size > 0 )); then
try_clean "Clear pip cache?" rm -rf "${pip_cache:?}"
if ((size > 0)); then
try_clean "Clear pip cache?" rm -rf "${pip_cache:?}"
fi
# 5. uv cache
uv_cache="$HOME/.cache/uv"
size=$(dir_size_kb "$uv_cache")
report "uv cache" "$size" "uv cache clean"
if (( size > 0 )); then
try_clean "Clear uv cache?" rm -rf "${uv_cache:?}"
if ((size > 0)); then
try_clean "Clear uv cache?" rm -rf "${uv_cache:?}"
fi
# 6. HuggingFace cache
@ -149,160 +149,159 @@ report "Suno AI cache" "$size" "manual review recommended"
go_cache="$HOME/.cache/go-build"
size=$(dir_size_kb "$go_cache")
report "Go build cache" "$size" "go clean -cache"
if (( size > 0 )); then
try_clean "Clear Go build cache?" rm -rf "${go_cache:?}"
if ((size > 0)); then
try_clean "Clear Go build cache?" rm -rf "${go_cache:?}"
fi
# 9. Yarn cache
yarn_cache="$HOME/.cache/yarn"
size=$(dir_size_kb "$yarn_cache")
report "Yarn cache" "$size" "yarn cache clean"
if (( size > 0 )); then
try_clean "Clear yarn cache?" rm -rf "${yarn_cache:?}"
if ((size > 0)); then
try_clean "Clear yarn cache?" rm -rf "${yarn_cache:?}"
fi
# 10. npm cache
npm_cache="$HOME/.npm/_cacache"
size=$(dir_size_kb "$npm_cache")
report "npm cache" "$size" "npm cache clean --force"
if (( size > 0 )); then
try_clean "Clear npm cache?" rm -rf "${npm_cache:?}"
if ((size > 0)); then
try_clean "Clear npm cache?" rm -rf "${npm_cache:?}"
fi
# 11. Electron cache
electron_cache="$HOME/.cache/electron"
size=$(dir_size_kb "$electron_cache")
report "Electron cache" "$size"
if (( size > 0 )); then
try_clean "Clear Electron cache?" rm -rf "${electron_cache:?}"
if ((size > 0)); then
try_clean "Clear Electron cache?" rm -rf "${electron_cache:?}"
fi
# 12. Thumbnails
thumb_cache="$HOME/.cache/thumbnails"
size=$(dir_size_kb "$thumb_cache")
report "Thumbnails cache" "$size"
if (( size > 0 )); then
try_clean "Clear thumbnails?" rm -rf "${thumb_cache:?}"
if ((size > 0)); then
try_clean "Clear thumbnails?" rm -rf "${thumb_cache:?}"
fi
# 13. vscode-cpptools cache
cpptools_cache="$HOME/.cache/vscode-cpptools"
size=$(dir_size_kb "$cpptools_cache")
if (( size > 102400 )); then
report "VS Code cpptools cache" "$size"
try_clean "Clear cpptools cache?" rm -rf "${cpptools_cache:?}"
if ((size > 102400)); then
report "VS Code cpptools cache" "$size"
try_clean "Clear cpptools cache?" rm -rf "${cpptools_cache:?}"
fi
# 14. Bazel cache
bazel_cache="$HOME/.cache/bazel"
size=$(dir_size_kb "$bazel_cache")
report "Bazel cache" "$size"
if (( size > 0 )); then
try_clean "Clear bazel cache?" rm -rf "${bazel_cache:?}"
if ((size > 0)); then
try_clean "Clear bazel cache?" rm -rf "${bazel_cache:?}"
fi
# 15. Browser caches (report only)
for browser_cache in \
"$HOME/.cache/thorium" \
"$HOME/.cache/google-chrome" \
"$HOME/.cache/mozilla" \
"$HOME/.cache/BraveSoftware"; do
if [[ -d "$browser_cache" ]]; then
name=$(basename "$browser_cache")
size=$(dir_size_kb "$browser_cache")
if (( size > 102400 )); then
report "Browser cache: $name" "$size" "close browser first"
fi
fi
"$HOME/.cache/thorium" \
"$HOME/.cache/google-chrome" \
"$HOME/.cache/mozilla" \
"$HOME/.cache/BraveSoftware"; do
if [[ -d "$browser_cache" ]]; then
name=$(basename "$browser_cache")
size=$(dir_size_kb "$browser_cache")
if ((size > 102400)); then
report "Browser cache: $name" "$size" "close browser first"
fi
fi
done
# 16. Docker
if command -v docker &>/dev/null; then
docker_dir="/var/lib/docker"
size=$(dir_size_kb "$docker_dir" || echo 0)
if (( size > 1048576 )); then
report "Docker (images+build cache)" "$size" "docker system prune -a"
try_clean "Docker system prune (removes all unused)?" \
docker system prune -af --volumes
fi
docker_dir="/var/lib/docker"
size=$(dir_size_kb "$docker_dir" || echo 0)
if ((size > 1048576)); then
report "Docker (images+build cache)" "$size" "docker system prune -a"
try_clean "Docker system prune (removes all unused)?" \
docker system prune -af --volumes
fi
fi
# 17. Orphan packages
orphans=$(pacman -Qdtq 2>/dev/null || true)
orphan_count=0
if [[ -n "$orphans" ]]; then
orphan_count=$(echo "$orphans" | wc -l)
orphan_count=$(echo "$orphans" | wc -l)
fi
if (( orphan_count > 0 )); then
orphan_size=$(echo "$orphans" | xargs pacman -Qi 2>/dev/null \
| awk '/Installed Size/{
if ((orphan_count > 0)); then
orphan_size=$(echo "$orphans" | xargs pacman -Qi 2>/dev/null |
awk '/Installed Size/{
size=$4; unit=$5;
if (unit ~ /GiB/) total += size*1048576;
else if (unit ~ /MiB/) total += size*1024;
else if (unit ~ /KiB/) total += size;
} END{printf "%d", total}' || echo 0)
report "Orphan packages ($orphan_count)" "$orphan_size" \
"sudo pacman -Rns \$(pacman -Qdtq)"
if confirm "Remove $orphan_count orphan packages?"; then
# shellcheck disable=SC2046
sudo pacman -Rns $(pacman -Qdtq) --noconfirm
printf "${GREEN} ✓ Orphans removed${RESET}\n"
fi
report "Orphan packages ($orphan_count)" "$orphan_size" \
"sudo pacman -Rns \$(pacman -Qdtq)"
if confirm "Remove $orphan_count orphan packages?"; then
# shellcheck disable=SC2046
sudo pacman -Rns $(pacman -Qdtq) --noconfirm
printf "%s ✓ Orphans removed%s\n" "${GREEN}" "${RESET}"
fi
fi
# 18. Journal logs — vacuum to 200M
journal_line=$(journalctl --disk-usage 2>&1 || true)
journal_kb=0
if [[ "$journal_line" =~ ([0-9.]+)G ]]; then
journal_kb=$(echo "${BASH_REMATCH[1]} * 1048576 / 1" | bc)
journal_kb=$(echo "${BASH_REMATCH[1]} * 1048576 / 1" | bc)
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
if (( journal_kb > 204800 )); then
excess=$(( journal_kb - 204800 ))
report "Journal logs (excess over 200M)" "$excess" "journalctl --vacuum-size=200M"
try_clean "Vacuum journal to 200M?" \
sudo journalctl --vacuum-size=200M
if ((journal_kb > 204800)); then
excess=$((journal_kb - 204800))
report "Journal logs (excess over 200M)" "$excess" "journalctl --vacuum-size=200M"
try_clean "Vacuum journal to 200M?" \
sudo journalctl --vacuum-size=200M
fi
# 19. AUR source dirs — old archives (>30 days)
aur_dir="$HOME/aur"
if [[ -d "$aur_dir" ]]; then
old_archives_kb=$(find "$aur_dir" \
\( -name "*.pkg.tar.zst" -o -name "*.tar.gz" -o -name "*.tar.xz" \
-o -name "*.zip" -o -name "*.tar.bz2" \) \
-mtime +30 -printf '%k\n' 2>/dev/null \
| awk '{t+=$1} END{print t+0}')
if (( old_archives_kb > 0 )); then
report "AUR old archives (>30d)" "$old_archives_kb" \
"find ~/aur '*.pkg.tar.zst' -mtime +30 -delete"
if confirm "Delete old AUR archives (>30 days)?"; then
find "$aur_dir" \
\( -name "*.pkg.tar.zst" -o -name "*.tar.gz" -o -name "*.tar.xz" \
-o -name "*.zip" -o -name "*.tar.bz2" \) \
-mtime +30 -delete
printf "${GREEN} ✓ Old AUR archives deleted${RESET}\n"
fi
fi
old_archives_kb=$(find "$aur_dir" \
\( -name "*.pkg.tar.zst" -o -name "*.tar.gz" -o -name "*.tar.xz" \
-o -name "*.zip" -o -name "*.tar.bz2" \) \
-mtime +30 -printf '%k\n' 2>/dev/null |
awk '{t+=$1} END{print t+0}')
if ((old_archives_kb > 0)); then
report "AUR old archives (>30d)" "$old_archives_kb" \
"find ~/aur '*.pkg.tar.zst' -mtime +30 -delete"
if confirm "Delete old AUR archives (>30 days)?"; then
find "$aur_dir" \
\( -name "*.pkg.tar.zst" -o -name "*.tar.gz" -o -name "*.tar.xz" \
-o -name "*.zip" -o -name "*.tar.bz2" \) \
-mtime +30 -delete
printf "%s ✓ Old AUR archives deleted%s\n" "${GREEN}" "${RESET}"
fi
fi
fi
# ───────────────────── 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 \
"$HOME/Downloads/too_big:~/Downloads/too_big" \
"$HOME/Downloads:~/Downloads total" \
"$HOME/inne:~/inne" \
"/Games:/Games — still playing?"; do
dir="${manual_entry%%:*}"
label="${manual_entry#*:}"
size=$(dir_size_kb "$dir")
if (( size > 0 )); then
hr=$(human_readable "$size")
printf "${RED}%-40s %10s${RESET} ${CYAN}(review manually)${RESET}\n" \
"$label" "$hr"
fi
"$HOME/Downloads/too_big:~/Downloads/too_big" \
"$HOME/Downloads:~/Downloads total" \
"$HOME/inne:~/inne" \
"/Games:/Games — still playing?"; do
dir="${manual_entry%%:*}"
label="${manual_entry#*:}"
size=$(dir_size_kb "$dir")
if ((size > 0)); then
hr=$(human_readable "$size")
printf "${RED}%-40s %10s${RESET} ${CYAN}(review manually)${RESET}\n" \
"$label" "$hr"
fi
done
# ───────────────────── summary ─────────────────────
@ -311,20 +310,20 @@ printf "\n%s\n" "$(printf '%.0s─' {1..60})"
total_hr=$(human_readable "$TOTAL_RECLAIMABLE")
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%\%}"
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
new_used=$(( used_kb - TOTAL_RECLAIMABLE ))
if (( new_used < 0 )); then new_used=0; fi
new_pct=$(( new_used * 100 / total_kb ))
printf "${BOLD}After cleanup: %s / %s (%d%%)${RESET}\n" \
"$(human_readable "$new_used")" "$(human_readable "$total_kb")" "$new_pct"
if ((TOTAL_RECLAIMABLE > 0)); then
new_used=$((used_kb - TOTAL_RECLAIMABLE))
if ((new_used < 0)); then new_used=0; fi
new_pct=$((new_used * 100 / total_kb))
printf "${BOLD}After cleanup: %s / %s (%d%%)${RESET}\n" \
"$(human_readable "$new_used")" "$(human_readable "$total_kb")" "$new_pct"
fi
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
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
SCRIPT_DIR="/data/local/tmp/focus_mode"
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"
# --- Radius in meters ---

File diff suppressed because it is too large Load Diff

View File

@ -36,135 +36,135 @@ touch "$DNS_LOG"
chmod 666 "$DNS_LOG" 2>/dev/null || true
log() {
local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$ts] $1" >> "$DNS_LOG"
local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$ts] $1" >>"$DNS_LOG"
}
rotate_log() {
local lines
lines="$(wc -l < "$DNS_LOG" 2>/dev/null || echo 0)"
if [ "$lines" -gt 500 ]; then
local tmp="$DNS_LOG.tmp"
tail -n 500 "$DNS_LOG" > "$tmp"
mv "$tmp" "$DNS_LOG"
fi
local lines
lines="$(wc -l <"$DNS_LOG" 2>/dev/null || echo 0)"
if [ "$lines" -gt 500 ]; then
local tmp="$DNS_LOG.tmp"
tail -n 500 "$DNS_LOG" >"$tmp"
mv "$tmp" "$DNS_LOG"
fi
}
acquire_lock() {
if [ -f "$PIDFILE" ]; then
local old_pid
old_pid="$(cat "$PIDFILE")"
if kill -0 "$old_pid" 2>/dev/null; then
local cmdline
cmdline="$(tr '\0' ' ' < "/proc/$old_pid/cmdline" 2>/dev/null)"
if echo "$cmdline" | grep -q "dns_enforcer"; then
echo "dns_enforcer already running (PID $old_pid)"
exit 0
fi
fi
rm -f "$PIDFILE"
fi
echo $$ > "$PIDFILE"
if [ -f "$PIDFILE" ]; then
local old_pid
old_pid="$(cat "$PIDFILE")"
if kill -0 "$old_pid" 2>/dev/null; then
local cmdline
cmdline="$(tr '\0' ' ' <"/proc/$old_pid/cmdline" 2>/dev/null)"
if echo "$cmdline" | grep -q "dns_enforcer"; then
echo "dns_enforcer already running (PID $old_pid)"
exit 0
fi
fi
rm -f "$PIDFILE"
fi
echo $$ >"$PIDFILE"
}
# ---- Private DNS ----
ensure_private_dns_off() {
local mode
mode="$(settings get global private_dns_mode 2>/dev/null)"
# Possible values: "off", "opportunistic", "hostname", null (default=opportunistic)
if [ "$mode" != "off" ]; then
settings put global private_dns_mode off 2>/dev/null
log "Private DNS was '$mode' - forced to 'off'"
fi
# Clear any pinned DoT hostname so the "hostname" mode cannot be
# re-enabled silently by Settings UI.
local spec
spec="$(settings get global private_dns_specifier 2>/dev/null)"
if [ -n "$spec" ] && [ "$spec" != "null" ]; then
settings delete global private_dns_specifier 2>/dev/null
log "Cleared private_dns_specifier (was '$spec')"
fi
local mode
mode="$(settings get global private_dns_mode 2>/dev/null)"
# Possible values: "off", "opportunistic", "hostname", null (default=opportunistic)
if [ "$mode" != "off" ]; then
settings put global private_dns_mode off 2>/dev/null
log "Private DNS was '$mode' - forced to 'off'"
fi
# Clear any pinned DoT hostname so the "hostname" mode cannot be
# re-enabled silently by Settings UI.
local spec
spec="$(settings get global private_dns_specifier 2>/dev/null)"
if [ -n "$spec" ] && [ "$spec" != "null" ]; then
settings delete global private_dns_specifier 2>/dev/null
log "Cleared private_dns_specifier (was '$spec')"
fi
}
# ---- iptables chain management ----
ensure_chain() {
local ipt="$1"
# Create the chain if missing.
if ! "$ipt" -L "$DNS_IPT_CHAIN" >/dev/null 2>&1; then
"$ipt" -N "$DNS_IPT_CHAIN" 2>/dev/null || {
log "ERROR: could not create $ipt chain $DNS_IPT_CHAIN"
return 1
}
log "Created $ipt chain $DNS_IPT_CHAIN"
fi
# Remove ALL existing OUTPUT -> chain jumps (handles duplicates from
# previous iptables lock races where -C returned error but -I succeeded).
local removed=0
while "$ipt" -D OUTPUT -j "$DNS_IPT_CHAIN" 2>/dev/null; do
removed=$((removed + 1))
done
# Insert exactly one jump at position 1 of OUTPUT.
if "$ipt" -I OUTPUT 1 -j "$DNS_IPT_CHAIN" 2>/dev/null; then
if [ "$removed" -gt 1 ]; then
log "De-duped $removed -> 1 OUTPUT jump for $ipt chain $DNS_IPT_CHAIN"
fi
else
log "ERROR: could not insert OUTPUT -> $DNS_IPT_CHAIN for $ipt"
return 1
fi
local ipt="$1"
# Create the chain if missing.
if ! "$ipt" -L "$DNS_IPT_CHAIN" >/dev/null 2>&1; then
"$ipt" -N "$DNS_IPT_CHAIN" 2>/dev/null || {
log "ERROR: could not create $ipt chain $DNS_IPT_CHAIN"
return 1
}
log "Created $ipt chain $DNS_IPT_CHAIN"
fi
# Remove ALL existing OUTPUT -> chain jumps (handles duplicates from
# previous iptables lock races where -C returned error but -I succeeded).
local removed=0
while "$ipt" -D OUTPUT -j "$DNS_IPT_CHAIN" 2>/dev/null; do
removed=$((removed + 1))
done
# Insert exactly one jump at position 1 of OUTPUT.
if "$ipt" -I OUTPUT 1 -j "$DNS_IPT_CHAIN" 2>/dev/null; then
if [ "$removed" -gt 1 ]; then
log "De-duped $removed -> 1 OUTPUT jump for $ipt chain $DNS_IPT_CHAIN"
fi
else
log "ERROR: could not insert OUTPUT -> $DNS_IPT_CHAIN for $ipt"
return 1
fi
}
fill_chain_v4() {
# Flush and refill so we always converge to the intended rule set.
iptables -F "$DNS_IPT_CHAIN" 2>/dev/null || return 1
# Drop DoT everywhere. This is a narrow port rule - there's no legit
# reason for arbitrary apps to talk 853/tcp on Android.
iptables -A "$DNS_IPT_CHAIN" -p tcp --dport 853 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
iptables -A "$DNS_IPT_CHAIN" -p udp --dport 853 -j REJECT \
--reject-with icmp-port-unreachable 2>/dev/null || true
# Flush and refill so we always converge to the intended rule set.
iptables -F "$DNS_IPT_CHAIN" 2>/dev/null || return 1
# Drop DoT everywhere. This is a narrow port rule - there's no legit
# reason for arbitrary apps to talk 853/tcp on Android.
iptables -A "$DNS_IPT_CHAIN" -p tcp --dport 853 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
iptables -A "$DNS_IPT_CHAIN" -p udp --dport 853 -j REJECT \
--reject-with icmp-port-unreachable 2>/dev/null || true
local ip
for ip in $DNS_DOH_IPV4; do
[ -z "$ip" ] && continue
[ "${ip#\#}" != "$ip" ] && continue
# Reject 443/tcp (DoH) and 53 (classic DNS) to well-known resolvers.
# We also block 53 so apps that try to talk to 1.1.1.1:53 directly
# (ignoring /etc/resolv.conf) still fall back to the system resolver.
iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 443 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 443 -j REJECT \
--reject-with icmp-port-unreachable 2>/dev/null || true
iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 53 -j REJECT \
--reject-with icmp-port-unreachable 2>/dev/null || true
iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
done
local ip
for ip in $DNS_DOH_IPV4; do
[ -z "$ip" ] && continue
[ "${ip#\#}" != "$ip" ] && continue
# Reject 443/tcp (DoH) and 53 (classic DNS) to well-known resolvers.
# We also block 53 so apps that try to talk to 1.1.1.1:53 directly
# (ignoring /etc/resolv.conf) still fall back to the system resolver.
iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 443 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 443 -j REJECT \
--reject-with icmp-port-unreachable 2>/dev/null || true
iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 53 -j REJECT \
--reject-with icmp-port-unreachable 2>/dev/null || true
iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
done
}
fill_chain_v6() {
ip6tables -F "$DNS_IPT_CHAIN" 2>/dev/null || return 1
ip6tables -A "$DNS_IPT_CHAIN" -p tcp --dport 853 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
ip6tables -A "$DNS_IPT_CHAIN" -p udp --dport 853 -j REJECT \
--reject-with icmp6-port-unreachable 2>/dev/null || true
ip6tables -F "$DNS_IPT_CHAIN" 2>/dev/null || return 1
ip6tables -A "$DNS_IPT_CHAIN" -p tcp --dport 853 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
ip6tables -A "$DNS_IPT_CHAIN" -p udp --dport 853 -j REJECT \
--reject-with icmp6-port-unreachable 2>/dev/null || true
local ip
for ip in $DNS_DOH_IPV6; do
[ -z "$ip" ] && continue
[ "${ip#\#}" != "$ip" ] && continue
ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 443 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 443 -j REJECT \
--reject-with icmp6-port-unreachable 2>/dev/null || true
ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 53 -j REJECT \
--reject-with icmp6-port-unreachable 2>/dev/null || true
ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
done
local ip
for ip in $DNS_DOH_IPV6; do
[ -z "$ip" ] && continue
[ "${ip#\#}" != "$ip" ] && continue
ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 443 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 443 -j REJECT \
--reject-with icmp6-port-unreachable 2>/dev/null || true
ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 53 -j REJECT \
--reject-with icmp6-port-unreachable 2>/dev/null || true
ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
done
}
# ---- State-change gate ----
@ -179,65 +179,71 @@ fill_chain_v6() {
# redundant identical rebuilds are skipped.
expected_rule_count() {
# 2 fixed DoT rules (tcp+udp/853) + 4 rules per configured DoH/DNS IP.
local n=2 ip
for ip in "$@"; do
[ -z "$ip" ] && continue
[ "${ip#\#}" != "$ip" ] && continue
n=$((n + 4))
done
echo "$n"
# 2 fixed DoT rules (tcp+udp/853) + 4 rules per configured DoH/DNS IP.
local n=2 ip
for ip in "$@"; do
[ -z "$ip" ] && continue
[ "${ip#\#}" != "$ip" ] && continue
n=$((n + 4))
done
echo "$n"
}
chain_intact() {
local ipt="$1" expected="$2" actual
"$ipt" -C OUTPUT -j "$DNS_IPT_CHAIN" >/dev/null 2>&1 || return 1
actual="$("$ipt" -S "$DNS_IPT_CHAIN" 2>/dev/null | grep -c '^-A')"
[ "$actual" = "$expected" ]
local ipt="$1" expected="$2" actual
"$ipt" -C OUTPUT -j "$DNS_IPT_CHAIN" >/dev/null 2>&1 || return 1
actual="$("$ipt" -S "$DNS_IPT_CHAIN" 2>/dev/null | grep -c '^-A')"
[ "$actual" = "$expected" ]
}
enforce_iptables() {
if command -v iptables >/dev/null 2>&1; then
if chain_intact iptables "$(expected_rule_count $DNS_DOH_IPV4)"; then
:
elif ensure_chain iptables && fill_chain_v4; then
log "iptables (v4) DNS chain rebuilt (was missing/tampered)"
fi
fi
if command -v ip6tables >/dev/null 2>&1; then
if chain_intact ip6tables "$(expected_rule_count $DNS_DOH_IPV6)"; then
:
elif ensure_chain ip6tables && fill_chain_v6; then
log "ip6tables (v6) DNS chain rebuilt (was missing/tampered)"
fi
fi
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
:
elif ensure_chain iptables && fill_chain_v4; then
log "iptables (v4) DNS chain rebuilt (was missing/tampered)"
fi
fi
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
:
elif ensure_chain ip6tables && fill_chain_v6; then
log "ip6tables (v6) DNS chain rebuilt (was missing/tampered)"
fi
fi
}
cleanup() {
# We intentionally leave the iptables chain in place on SIGTERM so
# stopping the enforcer for maintenance does not immediately re-open
# the DoH hole. `focus_ctl.sh dns-stop` does the explicit teardown.
log "dns_enforcer shutting down"
rm -f "$PIDFILE"
exit 0
# We intentionally leave the iptables chain in place on SIGTERM so
# stopping the enforcer for maintenance does not immediately re-open
# the DoH hole. `focus_ctl.sh dns-stop` does the explicit teardown.
log "dns_enforcer shutting down"
rm -f "$PIDFILE"
exit 0
}
trap cleanup INT TERM
main() {
acquire_lock
log "dns_enforcer started (PID=$$)"
acquire_lock
log "dns_enforcer started (PID=$$)"
# Initial arm-up
ensure_private_dns_off
enforce_iptables
# Initial arm-up
ensure_private_dns_off
enforce_iptables
while true; do
ensure_private_dns_off
enforce_iptables
rotate_log
sleep "$DNS_CHECK_INTERVAL"
done
while true; do
ensure_private_dns_off
enforce_iptables
rotate_log
sleep "$DNS_CHECK_INTERVAL"
done
}
main "$@"

File diff suppressed because it is too large Load Diff

View File

@ -13,166 +13,166 @@ PIDFILE="$STATE_DIR/daemon.pid"
# ---- PID lock: exit if already running ----
acquire_lock() {
mkdir -p "$STATE_DIR"
if [ -f "$PIDFILE" ]; then
local old_pid
old_pid="$(cat "$PIDFILE")"
if kill -0 "$old_pid" 2>/dev/null; then
# Verify the PID is actually a focus_daemon, not a reused PID
local cmdline
cmdline="$(cat /proc/$old_pid/cmdline 2>/dev/null | tr '\0' ' ')"
if echo "$cmdline" | grep -q "focus_daemon"; then
echo "Daemon already running (PID $old_pid), exiting."
exit 0
fi
fi
# Stale or reused pidfile
rm -f "$PIDFILE"
fi
echo $$ > "$PIDFILE"
mkdir -p "$STATE_DIR"
if [ -f "$PIDFILE" ]; then
local old_pid
old_pid="$(cat "$PIDFILE")"
if kill -0 "$old_pid" 2>/dev/null; then
# Verify the PID is actually a focus_daemon, not a reused PID
local cmdline
cmdline="$(tr '\0' ' ' <"/proc/$old_pid/cmdline" 2>/dev/null)"
if echo "$cmdline" | grep -q "focus_daemon"; then
echo "Daemon already running (PID $old_pid), exiting."
exit 0
fi
fi
# Stale or reused pidfile
rm -f "$PIDFILE"
fi
echo $$ >"$PIDFILE"
}
# ---- Logging ----
log() {
local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$ts] $1" >> "$LOG_FILE"
local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$ts] $1" >>"$LOG_FILE"
}
rotate_log() {
local lines
lines="$(wc -l < "$LOG_FILE" 2>/dev/null || echo 0)"
if [ "$lines" -gt "$LOG_MAX_LINES" ]; then
local tmp="$LOG_FILE.tmp"
tail -n "$LOG_MAX_LINES" "$LOG_FILE" > "$tmp"
mv "$tmp" "$LOG_FILE"
fi
local lines
lines="$(wc -l <"$LOG_FILE" 2>/dev/null || echo 0)"
if [ "$lines" -gt "$LOG_MAX_LINES" ]; then
local tmp="$LOG_FILE.tmp"
tail -n "$LOG_MAX_LINES" "$LOG_FILE" >"$tmp"
mv "$tmp" "$LOG_FILE"
fi
}
# ---- Build helper files for fast package checks ----
build_whitelist_file() {
echo "$WHITELIST" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$STATE_DIR/whitelist.txt"
# Sanity check: the WHITELIST string in config.sh is fragile - any
# literal double-quote inside a comment will close the heredoc and
# silently truncate the variable. Log the parsed line count so any
# future regression is visible in the log, and warn loudly if it
# falls below a known floor (we always have ~70+ entries).
local n
n=$(wc -l < "$STATE_DIR/whitelist.txt" 2>/dev/null | tr -d ' ')
log "Whitelist parsed: $n entries"
if [ "${n:-0}" -lt 30 ]; then
log "WARN: whitelist suspiciously small ($n lines) - check config.sh for stray quotes inside WHITELIST string"
fi
echo "$WHITELIST" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' |
sed 's/^[[:space:]]*//;s/[[:space:]]*$//' >"$STATE_DIR/whitelist.txt"
# Sanity check: the WHITELIST string in config.sh is fragile - any
# literal double-quote inside a comment will close the heredoc and
# silently truncate the variable. Log the parsed line count so any
# future regression is visible in the log, and warn loudly if it
# falls below a known floor (we always have ~70+ entries).
local n
n=$(wc -l <"$STATE_DIR/whitelist.txt" 2>/dev/null | tr -d ' ')
log "Whitelist parsed: $n entries"
if [ "${n:-0}" -lt 30 ]; then
log "WARN: whitelist suspiciously small ($n lines) - check config.sh for stray quotes inside WHITELIST string"
fi
}
build_night_whitelist_file() {
# 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.
echo "$NIGHT_WHITELIST" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$STATE_DIR/night_whitelist.txt"
local n
n=$(wc -l < "$STATE_DIR/night_whitelist.txt" 2>/dev/null | tr -d ' ')
log "Night-curfew whitelist parsed: $n entries"
if [ "${n:-0}" -lt 10 ]; then
log "WARN: night whitelist suspiciously small ($n lines) - check config.sh for stray quotes inside NIGHT_WHITELIST string"
fi
# 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.
echo "$NIGHT_WHITELIST" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' |
sed 's/^[[:space:]]*//;s/[[:space:]]*$//' >"$STATE_DIR/night_whitelist.txt"
local n
n=$(wc -l <"$STATE_DIR/night_whitelist.txt" 2>/dev/null | tr -d ' ')
log "Night-curfew whitelist parsed: $n entries"
if [ "${n:-0}" -lt 10 ]; then
log "WARN: night whitelist suspiciously small ($n lines) - check config.sh for stray quotes inside NIGHT_WHITELIST string"
fi
}
build_sysprotect_file() {
echo "$SYSTEM_NEVER_DISABLE" | grep -v '^[[:space:]]*$' \
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$STATE_DIR/sysprotect.txt"
echo "$SYSTEM_NEVER_DISABLE" | grep -v '^[[:space:]]*$' |
sed 's/^[[:space:]]*//;s/[[:space:]]*$//' >"$STATE_DIR/sysprotect.txt"
}
# $BLOCKED_SYSTEM_APPS is a static config value (never changes without a
# daemon restart), so - like build_sysprotect_file above - this only needs
# to run once at startup, not every enable_focus_mode() sweep.
build_blocked_sys_file() {
echo "$BLOCKED_SYSTEM_APPS" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$STATE_DIR/blocked_sys.txt"
echo "$BLOCKED_SYSTEM_APPS" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' |
sed 's/^[[:space:]]*//;s/[[:space:]]*$//' >"$STATE_DIR/blocked_sys.txt"
}
reconcile_disabled_apps() {
[ -f "$DISABLED_APPS_FILE" ] || return
[ -f "$DISABLED_APPS_FILE" ] || return
local tmp_disabled="$STATE_DIR/disabled_by_focus.tmp"
: > "$tmp_disabled"
local tmp_disabled="$STATE_DIR/disabled_by_focus.tmp"
: >"$tmp_disabled"
while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
if is_allowed "$pkg"; then
pm install-existing --user 0 "$pkg" >/dev/null 2>&1 || true
pm enable "$pkg" >/dev/null 2>&1 || true
log "Re-enabled allowed app during state reconciliation: $pkg"
continue
fi
if is_allowed "$pkg"; then
pm install-existing --user 0 "$pkg" >/dev/null 2>&1 || true
pm enable "$pkg" >/dev/null 2>&1 || true
log "Re-enabled allowed app during state reconciliation: $pkg"
continue
fi
echo "$pkg" >> "$tmp_disabled"
done < "$DISABLED_APPS_FILE"
echo "$pkg" >>"$tmp_disabled"
done <"$DISABLED_APPS_FILE"
mv "$tmp_disabled" "$DISABLED_APPS_FILE"
mv "$tmp_disabled" "$DISABLED_APPS_FILE"
}
# ---- Initialization ----
init() {
mkdir -p "$STATE_DIR"
touch "$LOG_FILE"
touch "$DISABLED_APPS_FILE"
# Ensure state files are writable (survives reboot / permission drift)
chmod 666 "$LOG_FILE" "$DISABLED_APPS_FILE" "$PIDFILE" 2>/dev/null
# Status file must be world-readable (companion app reads it).
# State dir must be world-writable+executable so the companion app can
# drop the recheck trigger file (it runs as a normal app UID).
chmod 777 "$STATE_DIR" 2>/dev/null
mkdir -p "$STATE_DIR"
touch "$LOG_FILE"
touch "$DISABLED_APPS_FILE"
# Ensure state files are writable (survives reboot / permission drift)
chmod 666 "$LOG_FILE" "$DISABLED_APPS_FILE" "$PIDFILE" 2>/dev/null
# Status file must be world-readable (companion app reads it).
# State dir must be world-writable+executable so the companion app can
# drop the recheck trigger file (it runs as a normal app UID).
chmod 777 "$STATE_DIR" 2>/dev/null
if [ "$HOME_LAT" = "0.000000" ] && [ "$HOME_LON" = "0.000000" ]; then
log "ERROR: Home coordinates not set! Edit config_secrets.sh first."
exit 1
fi
if [ "$HOME_LAT" = "0.000000" ] && [ "$HOME_LON" = "0.000000" ]; then
log "ERROR: Home coordinates not set! Edit config_secrets.sh first."
exit 1
fi
if ! echo "$HOME_LAT" | grep -Eq '^[-]?[0-9]+(\.[0-9]+)?$'; then
log "ERROR: HOME_LAT is invalid ('$HOME_LAT'). Expected decimal degrees in config_secrets.sh"
exit 1
fi
if ! echo "$HOME_LAT" | grep -Eq '^[-]?[0-9]+(\.[0-9]+)?$'; then
log "ERROR: HOME_LAT is invalid ('$HOME_LAT'). Expected decimal degrees in config_secrets.sh"
exit 1
fi
if ! echo "$HOME_LON" | grep -Eq '^[-]?[0-9]+(\.[0-9]+)?$'; then
log "ERROR: HOME_LON is invalid ('$HOME_LON'). Expected decimal degrees in config_secrets.sh"
exit 1
fi
if ! echo "$HOME_LON" | grep -Eq '^[-]?[0-9]+(\.[0-9]+)?$'; then
log "ERROR: HOME_LON is invalid ('$HOME_LON'). Expected decimal degrees in config_secrets.sh"
exit 1
fi
build_whitelist_file
build_night_whitelist_file
build_sysprotect_file
build_blocked_sys_file
refresh_default_handlers
rotate_log
build_whitelist_file
build_night_whitelist_file
build_sysprotect_file
build_blocked_sys_file
refresh_default_handlers
rotate_log
if [ -f "$MODE_FILE" ]; then
CURRENT_MODE="$(cat "$MODE_FILE")"
else
CURRENT_MODE="normal"
fi
if [ -f "$MODE_FILE" ]; then
CURRENT_MODE="$(cat "$MODE_FILE")"
else
CURRENT_MODE="normal"
fi
if [ "$CURRENT_MODE" = "focus" ]; then
reconcile_disabled_apps
fi
if [ "$CURRENT_MODE" = "focus" ]; then
reconcile_disabled_apps
fi
log "Focus mode daemon started (PID=$$, mode=$CURRENT_MODE, home=$HOME_LAT,$HOME_LON, radius=${RADIUS}m)"
log "Intervals: focus=${CHECK_INTERVAL_FOCUS}s normal=${CHECK_INTERVAL_NORMAL}s"
log "Focus mode daemon started (PID=$$, mode=$CURRENT_MODE, home=$HOME_LAT,$HOME_LON, radius=${RADIUS}m)"
log "Intervals: focus=${CHECK_INTERVAL_FOCUS}s normal=${CHECK_INTERVAL_NORMAL}s"
}
# ---- Location ----
get_location() {
dumpsys location 2>/dev/null \
| grep -oE '[-]?[0-9]{1,3}\.[0-9]{4,},[-]?[0-9]{1,3}\.[0-9]{4,}' \
| head -1
dumpsys location 2>/dev/null |
grep -oE '[-]?[0-9]{1,3}\.[0-9]{4,},[-]?[0-9]{1,3}\.[0-9]{4,}' |
head -1
}
# ---- Distance Calculation (Haversine via awk) ----
calc_distance() {
echo "$1 $2 $3 $4" | awk '{
echo "$1 $2 $3 $4" | awk '{
PI = 3.14159265358979323846
R = 6371000.0
lat1 = $1 * PI / 180.0
@ -195,27 +195,29 @@ calc_distance() {
# can never strand you behind the strict list — essentials stay reachable
# either way, but the day list is the less-surprising default.
_dec() {
# Strip leading zeros so a zero-padded HHMM ("0500", "0830") is not parsed
# as (sometimes invalid) octal by the shell's arithmetic. Portable across
# ash/mksh; keeps at least one digit so "0000" -> "0".
local n="$1"
while [ "${n#0}" != "$n" ] && [ "${#n}" -gt 1 ]; do n="${n#0}"; done
printf '%s' "$n"
# Strip leading zeros so a zero-padded HHMM ("0500", "0830") is not parsed
# as (sometimes invalid) octal by the shell's arithmetic. Portable across
# ash/mksh; keeps at least one digit so "0000" -> "0".
local n="$1"
while [ "${n#0}" != "$n" ] && [ "${#n}" -gt 1 ]; do n="${n#0}"; done
printf '%s' "$n"
}
is_curfew_now() {
local now start end
now="$(date +%H%M 2>/dev/null)"
case "$now" in
''|*[!0-9]*) return 1 ;;
esac
now="$(_dec "$now")"; start="$(_dec "$NIGHT_CURFEW_START")"; end="$(_dec "$NIGHT_CURFEW_END")"
if [ "$start" -le "$end" ]; then
[ "$now" -ge "$start" ] && [ "$now" -lt "$end" ]
else
# Window wraps past midnight (e.g. 2300 -> 0500).
[ "$now" -ge "$start" ] || [ "$now" -lt "$end" ]
fi
local now start end
now="$(date +%H%M 2>/dev/null)"
case "$now" in
'' | *[!0-9]*) return 1 ;;
esac
now="$(_dec "$now")"
start="$(_dec "$NIGHT_CURFEW_START")"
end="$(_dec "$NIGHT_CURFEW_END")"
if [ "$start" -le "$end" ]; then
[ "$now" -ge "$start" ] && [ "$now" -lt "$end" ]
else
# Window wraps past midnight (e.g. 2300 -> 0500).
[ "$now" -ge "$start" ] || [ "$now" -lt "$end" ]
fi
}
# Curfew is ACTIVE when enabled, not manually overridden, and either forced on
@ -229,192 +231,192 @@ is_curfew_now() {
# 10s tick for N enabled apps, all recomputing a value that cannot change
# within a single tick. Result is cached in _CURFEW_TICK_RESULT.
curfew_active() {
if [ -n "$_CURFEW_TICK_CACHED" ]; then
[ "$_CURFEW_TICK_RESULT" = "1" ]
return
fi
_CURFEW_TICK_CACHED=1
_CURFEW_TICK_RESULT=0
if [ "${NIGHT_CURFEW_ENABLED:-0}" = "1" ] && [ ! -e "$CURFEW_OVERRIDE_FILE" ]; then
if [ -e "$CURFEW_FORCE_FILE" ] || is_curfew_now; then
_CURFEW_TICK_RESULT=1
fi
fi
[ "$_CURFEW_TICK_RESULT" = "1" ]
if [ -n "$_CURFEW_TICK_CACHED" ]; then
[ "$_CURFEW_TICK_RESULT" = "1" ]
return
fi
_CURFEW_TICK_CACHED=1
_CURFEW_TICK_RESULT=0
if [ "${NIGHT_CURFEW_ENABLED:-0}" = "1" ] && [ ! -e "$CURFEW_OVERRIDE_FILE" ]; then
if [ -e "$CURFEW_FORCE_FILE" ] || is_curfew_now; then
_CURFEW_TICK_RESULT=1
fi
fi
[ "$_CURFEW_TICK_RESULT" = "1" ]
}
# ---- Check if package is allowed (whitelist or system-protected) ----
is_allowed() {
local pkg="$1"
# During the night curfew, swap the permissive day list for the strict
# night list. The sysprotect + default-handler guards below still apply on
# top of whichever list is active.
local list="$STATE_DIR/whitelist.txt"
if curfew_active; then
list="$STATE_DIR/night_whitelist.txt"
fi
# Exact match against the active whitelist file
if grep -qxF "$pkg" "$list" 2>/dev/null; then
return 0
fi
# Prefix match against system-protect file
while IFS= read -r prefix; do
[ -z "$prefix" ] && continue
case "$pkg" in
"$prefix"*) return 0 ;;
esac
done < "$STATE_DIR/sysprotect.txt"
# Hard-stop guard: refuse to disable any package that is the current
# default handler for a critical role (Dialer / SMS / Home / Contacts).
# Without this, a misconfigured WHITELIST can disable the default Phone
# app and Android falls back to com.android.settings/.FallbackHome -
# the persistent "Phone is starting..." screen with broken SystemUI
# gestures (no swipe-up recents). Recovering requires `pm enable` over
# ADB. Treat the guard as last-resort safety net independent of WHITELIST
# contents so a future config edit can never wipe these out.
is_default_handler "$pkg" && return 0
# 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.
if ! curfew_active \
&& grep -qxF "$pkg" "$STATE_DIR/default_browser.txt" 2>/dev/null; then
return 0
fi
return 1
local pkg="$1"
# During the night curfew, swap the permissive day list for the strict
# night list. The sysprotect + default-handler guards below still apply on
# top of whichever list is active.
local list="$STATE_DIR/whitelist.txt"
if curfew_active; then
list="$STATE_DIR/night_whitelist.txt"
fi
# Exact match against the active whitelist file
if grep -qxF "$pkg" "$list" 2>/dev/null; then
return 0
fi
# Prefix match against system-protect file
while IFS= read -r prefix; do
[ -z "$prefix" ] && continue
case "$pkg" in
"$prefix"*) return 0 ;;
esac
done <"$STATE_DIR/sysprotect.txt"
# Hard-stop guard: refuse to disable any package that is the current
# default handler for a critical role (Dialer / SMS / Home / Contacts).
# Without this, a misconfigured WHITELIST can disable the default Phone
# app and Android falls back to com.android.settings/.FallbackHome -
# the persistent "Phone is starting..." screen with broken SystemUI
# gestures (no swipe-up recents). Recovering requires `pm enable` over
# ADB. Treat the guard as last-resort safety net independent of WHITELIST
# contents so a future config edit can never wipe these out.
is_default_handler "$pkg" && return 0
# 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.
if ! curfew_active &&
grep -qxF "$pkg" "$STATE_DIR/default_browser.txt" 2>/dev/null; then
return 0
fi
return 1
}
# ---- Default handler detection ----
# Refreshed once per focus_daemon tick into $STATE_DIR/default_handlers.txt.
# Each line is a package name. Lookup is a cheap grep against this file.
refresh_default_handlers() {
local f="$STATE_DIR/default_handlers.txt"
local tmp="$f.tmp"
: > "$tmp"
# Default Home (launcher). resolve-activity prints "Activity Resolver Table:"
# on line 1 and "<pkg>/<.Activity>" on line 2 in --brief mode.
cmd package resolve-activity --brief \
-c android.intent.category.HOME -a android.intent.action.MAIN 2>/dev/null \
| awk -F/ 'NR==2 && $1 != "" {print $1}' >> "$tmp"
# Default Dialer
local dialer
dialer="$(cmd telecom get-default-dialer 2>/dev/null | tr -d '[:space:]')"
[ -n "$dialer" ] && echo "$dialer" >> "$tmp"
# Default SMS handler (settings provider key)
local sms
sms="$(settings get secure sms_default_application 2>/dev/null | tr -d '[:space:]')"
[ -n "$sms" ] && [ "$sms" != "null" ] && echo "$sms" >> "$tmp"
# Default input method (active keyboard). Disabling the active IME with
# 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
# curfew can never lock you out of typing.
local ime
ime="$(settings get secure default_input_method 2>/dev/null | cut -d/ -f1)"
[ -n "$ime" ] && [ "$ime" != "null" ] && echo "$ime" >> "$tmp"
sort -u "$tmp" -o "$f"
rm -f "$tmp"
local f="$STATE_DIR/default_handlers.txt"
local tmp="$f.tmp"
: >"$tmp"
# Default Home (launcher). resolve-activity prints "Activity Resolver Table:"
# on line 1 and "<pkg>/<.Activity>" on line 2 in --brief mode.
cmd package resolve-activity --brief \
-c android.intent.category.HOME -a android.intent.action.MAIN 2>/dev/null |
awk -F/ 'NR==2 && $1 != "" {print $1}' >>"$tmp"
# Default Dialer
local dialer
dialer="$(cmd telecom get-default-dialer 2>/dev/null | tr -d '[:space:]')"
[ -n "$dialer" ] && echo "$dialer" >>"$tmp"
# Default SMS handler (settings provider key)
local sms
sms="$(settings get secure sms_default_application 2>/dev/null | tr -d '[:space:]')"
[ -n "$sms" ] && [ "$sms" != "null" ] && echo "$sms" >>"$tmp"
# Default input method (active keyboard). Disabling the active IME with
# 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
# curfew can never lock you out of typing.
local ime
ime="$(settings get secure default_input_method 2>/dev/null | cut -d/ -f1)"
[ -n "$ime" ] && [ "$ime" != "null" ] && echo "$ime" >>"$tmp"
sort -u "$tmp" -o "$f"
rm -f "$tmp"
# Default Browser handler is tracked SEPARATELY and guarded only OUTSIDE
# the curfew window (see is_allowed). During curfew the whole point is to
# disable browsers, so the default-handler guard must not resurrect them.
local bf="$STATE_DIR/default_browser.txt"
cmd package resolve-activity --brief \
-a android.intent.action.VIEW -d http://example.com 2>/dev/null \
| awk -F/ 'NR==2 && $1 != "" {print $1}' > "$bf.tmp" 2>/dev/null
mv "$bf.tmp" "$bf" 2>/dev/null || : > "$bf"
# Default Browser handler is tracked SEPARATELY and guarded only OUTSIDE
# the curfew window (see is_allowed). During curfew the whole point is to
# disable browsers, so the default-handler guard must not resurrect them.
local bf="$STATE_DIR/default_browser.txt"
cmd package resolve-activity --brief \
-a android.intent.action.VIEW -d http://example.com 2>/dev/null |
awk -F/ 'NR==2 && $1 != "" {print $1}' >"$bf.tmp" 2>/dev/null
mv "$bf.tmp" "$bf" 2>/dev/null || : >"$bf"
}
is_default_handler() {
local pkg="$1"
grep -qxF "$pkg" "$STATE_DIR/default_handlers.txt" 2>/dev/null
local pkg="$1"
grep -qxF "$pkg" "$STATE_DIR/default_handlers.txt" 2>/dev/null
}
# ---- Focus Mode Control ----
enable_focus_mode() {
local first_entry=0
if [ "$CURRENT_MODE" != "focus" ]; then
first_entry=1
log "ENABLING focus mode - restricting non-whitelisted apps"
: > "$DISABLED_APPS_FILE"
fi
local first_entry=0
if [ "$CURRENT_MODE" != "focus" ]; then
first_entry=1
log "ENABLING focus mode - restricting non-whitelisted apps"
: >"$DISABLED_APPS_FILE"
fi
# Refresh default-handler list every tick. The user may switch dialer /
# SMS / launcher between sweeps; the guard in is_allowed() consults this
# list so a newly-promoted handler is never disabled.
refresh_default_handlers
# Refresh default-handler list every tick. The user may switch dialer /
# SMS / launcher between sweeps; the guard in is_allowed() consults this
# list so a newly-promoted handler is never disabled.
refresh_default_handlers
# Blocked system app list is static; built once in init() (see
# build_blocked_sys_file), not rebuilt on every sweep.
local blocked_sys="$STATE_DIR/blocked_sys.txt"
# Blocked system app list is static; built once in init() (see
# build_blocked_sys_file), not rebuilt on every sweep.
local blocked_sys="$STATE_DIR/blocked_sys.txt"
# Periodic rescan catches third-party apps the user re-enabled (e.g. via
# Play Store or `pm enable` in a terminal) since the last tick.
# -e = enabled only, so we skip apps that are already disabled.
local tmp_pkgs="$STATE_DIR/pkg_list.txt"
pm list packages -3 -e 2>/dev/null | sed 's/^package://' > "$tmp_pkgs"
local newly_disabled=0
while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
is_allowed "$pkg" && continue
if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then
grep -qxF "$pkg" "$DISABLED_APPS_FILE" 2>/dev/null \
|| echo "$pkg" >> "$DISABLED_APPS_FILE"
newly_disabled=$((newly_disabled + 1))
fi
done < "$tmp_pkgs"
rm -f "$tmp_pkgs"
# Periodic rescan catches third-party apps the user re-enabled (e.g. via
# Play Store or `pm enable` in a terminal) since the last tick.
# -e = enabled only, so we skip apps that are already disabled.
local tmp_pkgs="$STATE_DIR/pkg_list.txt"
pm list packages -3 -e 2>/dev/null | sed 's/^package://' >"$tmp_pkgs"
local newly_disabled=0
while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
is_allowed "$pkg" && continue
if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then
grep -qxF "$pkg" "$DISABLED_APPS_FILE" 2>/dev/null ||
echo "$pkg" >>"$DISABLED_APPS_FILE"
newly_disabled=$((newly_disabled + 1))
fi
done <"$tmp_pkgs"
rm -f "$tmp_pkgs"
# Disable-for-user-0 any blocked system apps (Play Store, browsers,
# package installer UI, terminal apps).
# IMPORTANT: We intentionally use pm disable-user (NOT pm uninstall) here.
# pm uninstall -k --user 0 removes the package from Android's user-0
# package registry. If the daemon is killed with SIGKILL during a reboot
# (bypassing the cleanup trap), those packages stay uninstalled across the
# reboot. Android's bootloop-protection (MTK and others) then detects
# missing critical system packages and triggers recovery / factory wipe.
# pm disable-user leaves the package registered but inactive, so the
# PackageManager scan at next boot succeeds and no wipe occurs.
while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then
grep -qxF "$pkg" "$DISABLED_APPS_FILE" 2>/dev/null \
|| echo "$pkg" >> "$DISABLED_APPS_FILE"
newly_disabled=$((newly_disabled + 1))
fi
done < "$blocked_sys"
# Disable-for-user-0 any blocked system apps (Play Store, browsers,
# package installer UI, terminal apps).
# IMPORTANT: We intentionally use pm disable-user (NOT pm uninstall) here.
# pm uninstall -k --user 0 removes the package from Android's user-0
# package registry. If the daemon is killed with SIGKILL during a reboot
# (bypassing the cleanup trap), those packages stay uninstalled across the
# reboot. Android's bootloop-protection (MTK and others) then detects
# missing critical system packages and triggers recovery / factory wipe.
# pm disable-user leaves the package registered but inactive, so the
# PackageManager scan at next boot succeeds and no wipe occurs.
while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then
grep -qxF "$pkg" "$DISABLED_APPS_FILE" 2>/dev/null ||
echo "$pkg" >>"$DISABLED_APPS_FILE"
newly_disabled=$((newly_disabled + 1))
fi
done <"$blocked_sys"
CURRENT_MODE="focus"
echo "focus" > "$MODE_FILE"
CURRENT_MODE="focus"
echo "focus" >"$MODE_FILE"
if [ "$first_entry" -eq 1 ]; then
local count
count=$(wc -l < "$DISABLED_APPS_FILE" 2>/dev/null || echo 0)
log "Focus mode enabled - disabled $count apps"
elif [ "$newly_disabled" -gt 0 ]; then
log "Focus mode re-sweep: re-disabled $newly_disabled apps (re-enabled by user?)"
fi
if [ "$first_entry" -eq 1 ]; then
local count
count=$(wc -l <"$DISABLED_APPS_FILE" 2>/dev/null || echo 0)
log "Focus mode enabled - disabled $count apps"
elif [ "$newly_disabled" -gt 0 ]; then
log "Focus mode re-sweep: re-disabled $newly_disabled apps (re-enabled by user?)"
fi
reconcile_disabled_apps
reconcile_disabled_apps
}
disable_focus_mode() {
[ "$CURRENT_MODE" = "normal" ] && return
log "DISABLING focus mode - re-enabling apps"
[ "$CURRENT_MODE" = "normal" ] && return
log "DISABLING focus mode - re-enabling apps"
local count=0
if [ -f "$DISABLED_APPS_FILE" ] && [ -s "$DISABLED_APPS_FILE" ]; then
# Re-enable all disabled apps (both 3rd-party and system apps).
# Both paths now use pm disable-user, so pm enable is the only
# restore command needed.
while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
pm enable "$pkg" >/dev/null 2>&1 && count=$((count + 1))
done < "$DISABLED_APPS_FILE"
: > "$DISABLED_APPS_FILE"
fi
local count=0
if [ -f "$DISABLED_APPS_FILE" ] && [ -s "$DISABLED_APPS_FILE" ]; then
# Re-enable all disabled apps (both 3rd-party and system apps).
# Both paths now use pm disable-user, so pm enable is the only
# restore command needed.
while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
pm enable "$pkg" >/dev/null 2>&1 && count=$((count + 1))
done <"$DISABLED_APPS_FILE"
: >"$DISABLED_APPS_FILE"
fi
CURRENT_MODE="normal"
echo "normal" > "$MODE_FILE"
log "Focus mode disabled - re-enabled $count apps"
CURRENT_MODE="normal"
echo "normal" >"$MODE_FILE"
log "Focus mode disabled - re-enabled $count apps"
}
# ---- Status snapshot for companion notification app ----
@ -422,40 +424,40 @@ disable_focus_mode() {
# Fields: mode, lat, lon, distance_m, threshold_m, radius_m, disabled_count,
# last_check_ts (unix), last_check_iso (human).
write_status_snapshot() {
local mode="$1" lat="$2" lon="$3" dist="$4" thr="$5"
local count iso ts cf ov frc
count="$(wc -l < "$DISABLED_APPS_FILE" 2>/dev/null | tr -d ' ' || echo 0)"
[ -z "$count" ] && count=0
ts="$(date +%s)"
iso="$(date '+%Y-%m-%d %H:%M:%S')"
# Curfew state for the companion app: 1/0 so it slots into the existing
# numeric JSON path. "curfew" = restrictions active now; "curfew_override"
# = the escape-hatch file is set (curfew suspended).
if curfew_active; then cf=1; else cf=0; fi
if [ -e "$CURFEW_OVERRIDE_FILE" ]; then ov=1; else ov=0; fi
# "curfew_force" = the demo/test force file is set (curfew forced on
# regardless of clock). Lets the companion app show Start/Stop demo.
if [ -e "$CURFEW_FORCE_FILE" ]; then frc=1; else frc=0; fi
local tmp="$STATUS_FILE.tmp"
# Shell-emitted JSON — keep values numeric where possible, strings quoted.
{
printf '{'
printf '"mode":"%s",' "$mode"
printf '"lat":"%s",' "${lat:-}"
printf '"lon":"%s",' "${lon:-}"
printf '"distance_m":%s,' "${dist:-null}"
printf '"threshold_m":%s,' "${thr:-null}"
printf '"radius_m":%s,' "$RADIUS"
printf '"disabled_count":%s,' "$count"
printf '"curfew":%s,' "$cf"
printf '"curfew_override":%s,' "$ov"
printf '"curfew_force":%s,' "$frc"
printf '"last_check_ts":%s,' "$ts"
printf '"last_check_iso":"%s"' "$iso"
printf '}\n'
} > "$tmp" 2>/dev/null || return 0
mv "$tmp" "$STATUS_FILE" 2>/dev/null || true
chmod 644 "$STATUS_FILE" 2>/dev/null || true
local mode="$1" lat="$2" lon="$3" dist="$4" thr="$5"
local count iso ts cf ov frc
count="$(wc -l <"$DISABLED_APPS_FILE" 2>/dev/null | tr -d ' ' || echo 0)"
[ -z "$count" ] && count=0
ts="$(date +%s)"
iso="$(date '+%Y-%m-%d %H:%M:%S')"
# Curfew state for the companion app: 1/0 so it slots into the existing
# numeric JSON path. "curfew" = restrictions active now; "curfew_override"
# = the escape-hatch file is set (curfew suspended).
if curfew_active; then cf=1; else cf=0; fi
if [ -e "$CURFEW_OVERRIDE_FILE" ]; then ov=1; else ov=0; fi
# "curfew_force" = the demo/test force file is set (curfew forced on
# regardless of clock). Lets the companion app show Start/Stop demo.
if [ -e "$CURFEW_FORCE_FILE" ]; then frc=1; else frc=0; fi
local tmp="$STATUS_FILE.tmp"
# Shell-emitted JSON — keep values numeric where possible, strings quoted.
{
printf '{'
printf '"mode":"%s",' "$mode"
printf '"lat":"%s",' "${lat:-}"
printf '"lon":"%s",' "${lon:-}"
printf '"distance_m":%s,' "${dist:-null}"
printf '"threshold_m":%s,' "${thr:-null}"
printf '"radius_m":%s,' "$RADIUS"
printf '"disabled_count":%s,' "$count"
printf '"curfew":%s,' "$cf"
printf '"curfew_override":%s,' "$ov"
printf '"curfew_force":%s,' "$frc"
printf '"last_check_ts":%s,' "$ts"
printf '"last_check_iso":"%s"' "$iso"
printf '}\n'
} >"$tmp" 2>/dev/null || return 0
mv "$tmp" "$STATUS_FILE" 2>/dev/null || true
chmod 644 "$STATUS_FILE" 2>/dev/null || true
}
# ---- Sleep with early-wake on recheck trigger ----
@ -463,25 +465,25 @@ write_status_snapshot() {
# early. The file can be touched by the companion app (via "Re-check now"
# button) or by `focus_ctl.sh recheck` from a shell.
sleep_with_recheck() {
local total="$1"
local elapsed=0
while [ "$elapsed" -lt "$total" ]; do
if [ -e "$RECHECK_TRIGGER" ]; then
rm -f "$RECHECK_TRIGGER" 2>/dev/null
log "Manual re-check triggered"
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
local total="$1"
local elapsed=0
while [ "$elapsed" -lt "$total" ]; do
if [ -e "$RECHECK_TRIGGER" ]; then
rm -f "$RECHECK_TRIGGER" 2>/dev/null
log "Manual re-check triggered"
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
}
# ---- Signal handlers ----
cleanup() {
log "Daemon shutting down - re-enabling all apps"
disable_focus_mode
rm -f "$PIDFILE"
exit 0
log "Daemon shutting down - re-enabling all apps"
disable_focus_mode
rm -f "$PIDFILE"
exit 0
}
# HUP is intentionally NOT trapped so the daemon survives ADB disconnects.
@ -490,51 +492,52 @@ trap cleanup INT TERM
# ---- Main Loop ----
main() {
acquire_lock
init
acquire_lock
init
while true; do
# Invalidate the per-tick curfew_active() memo (see its definition).
_CURFEW_TICK_CACHED=""
while true; do
# Invalidate the per-tick curfew_active() memo (see its definition).
_CURFEW_TICK_CACHED=""
location="$(get_location)"
location="$(get_location)"
if [ -n "$location" ]; then
lat="$(echo "$location" | cut -d',' -f1)"
lon="$(echo "$location" | cut -d',' -f2)"
distance="$(calc_distance "$lat" "$lon" "$HOME_LAT" "$HOME_LON")"
if [ -n "$location" ]; then
lat="$(echo "$location" | cut -d',' -f1)"
lon="$(echo "$location" | cut -d',' -f2)"
distance="$(calc_distance "$lat" "$lon" "$HOME_LAT" "$HOME_LON")"
if [ "$CURRENT_MODE" = "focus" ]; then
threshold=$((RADIUS + HYSTERESIS))
else
threshold=$((RADIUS - HYSTERESIS))
fi
if [ "$CURRENT_MODE" = "focus" ]; then
threshold=$((RADIUS + HYSTERESIS))
else
threshold=$((RADIUS - HYSTERESIS))
fi
if [ "$distance" -le "$threshold" ] 2>/dev/null; then
enable_focus_mode
else
disable_focus_mode
fi
if [ "$distance" -le "$threshold" ] 2>/dev/null; then
enable_focus_mode
else
disable_focus_mode
fi
curfew_state="day"; curfew_active && curfew_state="CURFEW"
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"
else
log "Location unavailable - defaulting to focus mode (restrictions ON)"
enable_focus_mode
write_status_snapshot "$CURRENT_MODE" "" "" "null" "null"
fi
curfew_state="day"
curfew_active && curfew_state="CURFEW"
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"
else
log "Location unavailable - defaulting to focus mode (restrictions ON)"
enable_focus_mode
write_status_snapshot "$CURRENT_MODE" "" "" "null" "null"
fi
# Dynamic interval: shorter at home (can charge), longer away (save battery).
# sleep_with_recheck returns early if the companion app requests a recheck.
if [ "$CURRENT_MODE" = "focus" ]; then
sleep_with_recheck "$CHECK_INTERVAL_FOCUS"
else
sleep_with_recheck "$CHECK_INTERVAL_NORMAL"
fi
# Dynamic interval: shorter at home (can charge), longer away (save battery).
# sleep_with_recheck returns early if the companion app requests a recheck.
if [ "$CURRENT_MODE" = "focus" ]; then
sleep_with_recheck "$CHECK_INTERVAL_FOCUS"
else
sleep_with_recheck "$CHECK_INTERVAL_NORMAL"
fi
rotate_log
done
rotate_log
done
}
main "$@"

View File

@ -11,13 +11,23 @@ cd "$SCRIPT_DIR"
SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}"
# 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.
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"
[ -d "$BUILD_TOOLS_DIR" ] || { 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; }
[ -d "$BUILD_TOOLS_DIR" ] || {
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"
D8="$BUILD_TOOLS_DIR/d8"
@ -25,7 +35,10 @@ ZIPALIGN="$BUILD_TOOLS_DIR/zipalign"
APKSIGNER="$BUILD_TOOLS_DIR/apksigner"
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
BUILD="$SCRIPT_DIR/build"
@ -35,23 +48,23 @@ mkdir -p "$BUILD/compiled-res" "$BUILD/classes" "$BUILD/dex"
# ---- Compile resources (none for now, but aapt2 requires the dir) ----
mkdir -p "$SCRIPT_DIR/res"
if find "$SCRIPT_DIR/res" -type f -print -quit | grep -q .; then
"$AAPT2" compile --dir "$SCRIPT_DIR/res" -o "$BUILD/compiled-res"
"$AAPT2" compile --dir "$SCRIPT_DIR/res" -o "$BUILD/compiled-res"
fi
# ---- Link resources + manifest into base APK ----
LINK_ARGS=(
--manifest "$SCRIPT_DIR/AndroidManifest.xml"
-I "$PLATFORM_JAR"
--java "$BUILD"
--min-sdk-version 29
--target-sdk-version 35
--version-code 1
--version-name 1.0.0
-o "$BUILD/base.apk"
--manifest "$SCRIPT_DIR/AndroidManifest.xml"
-I "$PLATFORM_JAR"
--java "$BUILD"
--min-sdk-version 29
--target-sdk-version 35
--version-code 1
--version-name 1.0.0
-o "$BUILD/base.apk"
)
# Include any compiled res archives.
for rfile in "$BUILD"/compiled-res/*.flat; do
[ -e "$rfile" ] && LINK_ARGS+=("$rfile")
[ -e "$rfile" ] && LINK_ARGS+=("$rfile")
done
"$AAPT2" link "${LINK_ARGS[@]}"
@ -61,9 +74,9 @@ JAVA_SRCS=()
while IFS= read -r -d '' f; do JAVA_SRCS+=("$f"); done < <(find "$SCRIPT_DIR/java" "$BUILD" -name '*.java' -print0)
javac -source 11 -target 11 \
-classpath "$PLATFORM_JAR" \
-d "$BUILD/classes" \
"${JAVA_SRCS[@]}"
-classpath "$PLATFORM_JAR" \
-d "$BUILD/classes" \
"${JAVA_SRCS[@]}"
# ---- Dex ----
CLASS_FILES=()
@ -80,18 +93,18 @@ cp "$BUILD/base.apk" "$BUILD/unsigned.apk"
# ---- Sign with debug key (auto-generated on first build) ----
KEYSTORE="$SCRIPT_DIR/debug.keystore"
if [ ! -f "$KEYSTORE" ]; then
echo "Generating debug keystore..."
keytool -genkeypair -v \
-keystore "$KEYSTORE" -storepass android -keypass android \
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Focus Status Debug, OU=Dev, O=Dev, L=NA, ST=NA, C=NA" \
>/dev/null 2>&1
echo "Generating debug keystore..."
keytool -genkeypair -v \
-keystore "$KEYSTORE" -storepass android -keypass android \
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Focus Status Debug, OU=Dev, O=Dev, L=NA, ST=NA, C=NA" \
>/dev/null 2>&1
fi
"$APKSIGNER" sign \
--ks "$KEYSTORE" --ks-pass pass:android \
--key-pass pass:android \
--out "$BUILD/focus_status.apk" \
"$BUILD/aligned.apk"
--ks "$KEYSTORE" --ks-pass pass:android \
--key-pass pass:android \
--out "$BUILD/focus_status.apk" \
"$BUILD/aligned.apk"
echo ""
echo "Built: $BUILD/focus_status.apk"

View File

@ -7,13 +7,13 @@ PASS=0
FAIL=0
_t_pass() {
PASS=$((PASS + 1))
printf ' OK: %s\n' "$1"
PASS=$((PASS + 1))
printf ' OK: %s\n' "$1"
}
_t_fail() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$1"
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$1"
}
TEST_TMPDIR="$(mktemp -d)"
@ -25,189 +25,198 @@ mkdir -p "${XDG_STATE_HOME}"
source "${SCRIPT_DIR}/../adb_common.sh"
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() {
if [[ "$#" -eq 1 && "$1" == "devices" ]]; then
printf 'List of devices attached\nSERIAL123\tdevice\n'
return 0
fi
if [[ "$#" -eq 1 && "$1" == "devices" ]]; then
printf 'List of devices attached\nSERIAL123\tdevice\n'
return 0
fi
if [[ "$1" == "-s" && "$3" == "shell" && "$4" == "getprop" && "$5" == "ro.product.model" ]]; then
printf '%s\r\n' "${ADB_MOCK_MODEL}"
return 0
fi
if [[ "$1" == "-s" && "$3" == "shell" && "$4" == "getprop" && "$5" == "ro.product.model" ]]; then
printf '%s\r\n' "${ADB_MOCK_MODEL}"
return 0
fi
if [[ "$1" == "-s" && "$3" == "shell" && "$4" == "getprop" && "$5" == "ro.build.fingerprint" ]]; then
printf '%s\r\n' "${ADB_MOCK_FINGERPRINT}"
return 0
fi
if [[ "$1" == "-s" && "$3" == "shell" && "$4" == "getprop" && "$5" == "ro.build.fingerprint" ]]; then
printf '%s\r\n' "${ADB_MOCK_FINGERPRINT}"
return 0
fi
if [[ "$1" == "-s" && "$3" == "shell" && "$4" == "su" && "$5" == "--mount-master" && "$6" == "-c" && "$7" == "echo ok" ]]; then
printf 'ok\n'
return 0
fi
if [[ "$1" == "-s" && "$3" == "shell" && "$4" == "su" && "$5" == "--mount-master" && "$6" == "-c" && "$7" == "echo ok" ]]; then
printf 'ok\n'
return 0
fi
printf 'Unexpected adb invocation:' >&2
printf ' %q' "$@" >&2
printf '\n' >&2
return 1
printf 'Unexpected adb invocation:' >&2
printf ' %q' "$@" >&2
printf '\n' >&2
return 1
}
run_test() {
local name="$1"
shift
local name="$1"
shift
if "$@"; then
_t_pass "${name}"
else
_t_fail "${name}"
fi
if "$@"; then
_t_pass "${name}"
else
_t_fail "${name}"
fi
}
test_box_does_not_crash() {
_box "Test title" "line 1" "line 2" >/dev/null 2>&1
_box "Test title" "line 1" "line 2" >/dev/null 2>&1
}
test_check_cooldown_zero_allows() {
LAST_RUN_DIR="${TEST_TMPDIR}/cooldown-zero"
adb_check_cooldown 0 "test_marker"
LAST_RUN_DIR="${TEST_TMPDIR}/cooldown-zero"
adb_check_cooldown 0 "test_marker"
}
test_check_cooldown_blocks_when_marker_fresh() {
LAST_RUN_DIR="${TEST_TMPDIR}/cooldown-block"
mkdir -p "${LAST_RUN_DIR}"
date +%s >"${LAST_RUN_DIR}/fresh_marker"
LAST_RUN_DIR="${TEST_TMPDIR}/cooldown-block"
mkdir -p "${LAST_RUN_DIR}"
date +%s >"${LAST_RUN_DIR}/fresh_marker"
if adb_check_cooldown 9999 "fresh_marker"; then
return 1
fi
if adb_check_cooldown 9999 "fresh_marker"; then
return 1
fi
return 0
return 0
}
test_mark_last_run_creates_marker() {
LAST_RUN_DIR="${TEST_TMPDIR}/mark-last-run"
adb_mark_last_run "run_test"
[[ -f "${LAST_RUN_DIR}/run_test" ]]
LAST_RUN_DIR="${TEST_TMPDIR}/mark-last-run"
adb_mark_last_run "run_test"
[[ -f "${LAST_RUN_DIR}/run_test" ]]
}
test_acquire_lock_creates_lock_directory() {
LOCK_DIR="${TEST_TMPDIR}/lock-dir-create"
LOCK_FILE=""
adb_acquire_lock >/dev/null 2>&1
[[ -d "${LOCK_FILE}" && -f "${LOCK_FILE}/pid" ]]
_adb_release_lock
LOCK_DIR="${TEST_TMPDIR}/lock-dir-create"
LOCK_FILE=""
adb_acquire_lock >/dev/null 2>&1
[[ -d "${LOCK_FILE}" && -f "${LOCK_FILE}/pid" ]]
_adb_release_lock
}
test_acquire_lock_removes_stale_lock_directory() {
LOCK_DIR="${TEST_TMPDIR}/lock-dir-stale"
LOCK_FILE="${LOCK_DIR}/run_phone.lock"
mkdir -p "${LOCK_FILE}"
printf '999999\n' >"${LOCK_FILE}/pid"
LOCK_DIR="${TEST_TMPDIR}/lock-dir-stale"
LOCK_FILE="${LOCK_DIR}/run_phone.lock"
mkdir -p "${LOCK_FILE}"
printf '999999\n' >"${LOCK_FILE}/pid"
adb_acquire_lock >/dev/null 2>&1
[[ -d "${LOCK_FILE}" && -f "${LOCK_FILE}/pid" ]]
_adb_release_lock
adb_acquire_lock >/dev/null 2>&1
[[ -d "${LOCK_FILE}" && -f "${LOCK_FILE}/pid" ]]
_adb_release_lock
}
test_sanitize_device_string_removes_dangerous_chars() {
local sanitized
sanitized="$(_sanitize_device_string $'abc DEF-._:/$`";\n')"
local sanitized
sanitized="$(_sanitize_device_string $'abc DEF-._:/$`";\n')"
[[ "${sanitized}" == 'abc DEF-._:/' ]]
[[ "${sanitized}" == 'abc DEF-._:/' ]]
}
test_save_trusted_device_sanitizes_and_quotes() {
local trusted_contents
local trusted_contents
export ADB_SERIAL='SERIAL123$() ;'
adb_save_trusted_device
# Literal payload on purpose - see the note on ADB_MOCK_FINGERPRINT.
# shellcheck disable=SC2016
export ADB_SERIAL='SERIAL123$() ;'
adb_save_trusted_device
[[ -f "${TRUSTED_DEVICE_FILE}" ]] || return 1
trusted_contents="$(cat "${TRUSTED_DEVICE_FILE}")"
[[ -f "${TRUSTED_DEVICE_FILE}" ]] || return 1
trusted_contents="$(cat "${TRUSTED_DEVICE_FILE}")"
[[ "${trusted_contents}" != *'$('* ]]
[[ "${trusted_contents}" != *';'* ]]
[[ "${trusted_contents}" != *'`'* ]]
# Asserting the sanitiser stripped these metacharacters, so the patterns
# must stay literal rather than expand.
# shellcheck disable=SC2016
[[ "${trusted_contents}" != *'$('* ]]
[[ "${trusted_contents}" != *';'* ]]
[[ "${trusted_contents}" != *'`'* ]]
local -a trusted_values=()
mapfile -t trusted_values < <(
bash -c '
local -a trusted_values=()
mapfile -t trusted_values < <(
bash -c '
set -euo pipefail
source "$1"
printf "%s\n" "${TRUSTED_SERIAL:-}" "${TRUSTED_MODEL:-}" "${TRUSTED_FINGERPRINT:-}"
' bash "${TRUSTED_DEVICE_FILE}"
)
)
[[ "${trusted_values[0]:-}" == 'SERIAL123 ' ]]
[[ "${trusted_values[1]:-}" == 'Pixel 7rm -rf /dangerline2' ]]
[[ "${trusted_values[2]:-}" == 'google/pixel:14/UP1A.231005.007/evilcmd' ]]
[[ "${trusted_values[0]:-}" == 'SERIAL123 ' ]]
[[ "${trusted_values[1]:-}" == 'Pixel 7rm -rf /dangerline2' ]]
[[ "${trusted_values[2]:-}" == 'google/pixel:14/UP1A.231005.007/evilcmd' ]]
}
test_verify_root_uses_root_shell() {
export ADB_SERIAL='SERIAL123'
adb_verify_root >/dev/null 2>&1
export ADB_SERIAL='SERIAL123'
adb_verify_root >/dev/null 2>&1
}
test_select_device_rejects_multiple_devices_even_with_trusted_record() {
unset ADB_SERIAL
unset ADB_SERIAL
adb_list_serials() {
printf 'SERIAL123\nSERIAL999\n'
}
adb_list_serials() {
printf 'SERIAL123\nSERIAL999\n'
}
cat >"${TRUSTED_DEVICE_FILE}" <<'EOF'
cat >"${TRUSTED_DEVICE_FILE}" <<'EOF'
TRUSTED_SERIAL='SERIAL123'
TRUSTED_MODEL='Pixel 7rm -rf /dangerline2'
TRUSTED_FINGERPRINT='google/pixel:14/UP1A.231005.007/evilcmd'
EOF
if (adb_select_device >/dev/null 2>&1); then
return 1
fi
if (adb_select_device >/dev/null 2>&1); then
return 1
fi
return 0
return 0
}
test_verify_trusted_identity_accepts_exact_match() {
export ADB_SERIAL='SERIAL123'
adb_save_trusted_device
adb_verify_trusted_identity >/dev/null 2>&1
export ADB_SERIAL='SERIAL123'
adb_save_trusted_device
adb_verify_trusted_identity >/dev/null 2>&1
}
test_verify_trusted_identity_rejects_model_mismatch() {
export ADB_SERIAL='SERIAL123'
adb_save_trusted_device
export ADB_SERIAL='SERIAL123'
adb_save_trusted_device
cat >"${TRUSTED_DEVICE_FILE}" <<'EOF'
cat >"${TRUSTED_DEVICE_FILE}" <<'EOF'
TRUSTED_SERIAL='SERIAL123'
TRUSTED_MODEL='Different Model'
TRUSTED_FINGERPRINT='google/pixel:14/UP1A.231005.007/evilcmd'
EOF
if (adb_verify_trusted_identity >/dev/null 2>&1); then
return 1
fi
if (adb_verify_trusted_identity >/dev/null 2>&1); then
return 1
fi
return 0
return 0
}
test_verify_trusted_identity_rejects_fingerprint_mismatch() {
export ADB_SERIAL='SERIAL123'
adb_save_trusted_device
export ADB_SERIAL='SERIAL123'
adb_save_trusted_device
cat >"${TRUSTED_DEVICE_FILE}" <<'EOF'
cat >"${TRUSTED_DEVICE_FILE}" <<'EOF'
TRUSTED_SERIAL='SERIAL123'
TRUSTED_MODEL='Pixel 7rm -rf /dangerline2'
TRUSTED_FINGERPRINT='different/fingerprint'
EOF
if (adb_verify_trusted_identity >/dev/null 2>&1); then
return 1
fi
if (adb_verify_trusted_identity >/dev/null 2>&1); then
return 1
fi
return 0
return 0
}
run_test "_box output without crash" test_box_does_not_crash

View File

@ -12,11 +12,11 @@ SRC="$SCRIPT_DIR/../../tether_enforcer.sh"
RUN="$(mktemp -d)"
cleanup() {
if [ -n "${PID:-}" ]; then
kill "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true # let its TERM handler finish before rm
fi
rm -rf "$RUN"
if [ -n "${PID:-}" ]; then
kill "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true # let its TERM handler finish before rm
fi
rm -rf "$RUN"
}
trap cleanup EXIT
mkdir -p "$RUN/bin" "$RUN/state" "$RUN/ipt" "$RUN/settings" "$RUN/calls" "$RUN/app"
@ -24,7 +24,7 @@ STATE="$RUN/state"
# --- fake config.sh in the daemon's own dir (it sources "$SCRIPT_DIR/config.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 MODE_FILE="$STATE/current_mode.txt"
export TETHER_ENFORCER_ENABLED=1
@ -40,7 +40,7 @@ export TETHER_FORCE_FILE="$STATE/tether_force_on"
EOF
# --- stubs ---
cat > "$RUN/bin/settings" <<EOF
cat >"$RUN/bin/settings" <<EOF
#!/usr/bin/env bash
db="$RUN/settings/tether_offload_disabled"
echo "settings \$*" >> "$RUN/calls/settings.log"
@ -52,7 +52,7 @@ esac
exit 0
EOF
# iptables/ip6tables: minimal chain-state model so chain_intact converges.
cat > "$RUN/bin/iptables" <<EOF
cat >"$RUN/bin/iptables" <<EOF
#!/usr/bin/env bash
bin="\$(basename "\$0")"; s="$RUN/ipt/\$bin"; mkdir -p "\$s"
[ "\$1" = "-w" ] && shift 2
@ -70,54 +70,66 @@ esac
exit \$?
EOF
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 13\n' > "$RUN/bin/getprop"
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"
chmod +x "$RUN/bin/"*
export PATH="$RUN/bin:$PATH"
PASS=0; FAIL=0
ok() { PASS=$((PASS + 1)); printf ' OK: %s\n' "$1"; }
no() { FAIL=$((FAIL + 1)); printf ' FAIL: %s\n' "$1"; }
PASS=0
FAIL=0
ok() {
PASS=$((PASS + 1))
printf ' OK: %s\n' "$1"
}
no() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$1"
}
applied() { [ -f "$STATE/tether_applied" ]; }
offload() { cat "$RUN/settings/tether_offload_disabled" 2>/dev/null || echo "<unset>"; }
v4jump() { [ -f "$RUN/ipt/iptables/jump" ]; }
v6jump() { [ -f "$RUN/ipt/ip6tables/jump" ]; }
v4jump() { [ -f "$RUN/ipt/iptables/jump" ]; }
v6jump() { [ -f "$RUN/ipt/ip6tables/jump" ]; }
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
# [A] Away from home -> block OFF
applied && no "A: applied while away" || ok "A: not applied while away"
[ "$(offload)" = "<unset>" ] && ok "A: offload untouched away" || no "A: offload changed away"
v4jump && no "A: v4 jump while away" || ok "A: no v4 jump while away"
if applied; then no "A: applied while away"; else ok "A: not applied while away"; fi
if [ "$(offload)" = "<unset>" ]; then ok "A: offload untouched away"; else no "A: offload changed away"; fi
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)
touch "$STATE/tether_force_on"; sleep 3
applied && ok "B: applied on force" || no "B: not applied on force"
[ "$(offload)" = "1" ] && ok "B: offload disabled" || no "B: offload=$(offload)"
v4jump && ok "B: v4 FORWARD jump pinned" || no "B: no v4 jump"
v6jump && ok "B: v6 FORWARD jump pinned" || no "B: no v6 jump"
[ "$(v4rules)" = "1" ] && ok "B: single REJECT rule (no rebuild loop)" || no "B: v4 rules=$(v4rules)"
[ "$(softap)" -ge 1 ] && ok "B: stop-softap invoked" || no "B: softap not stopped"
[ "$(cat "$STATE/tether_offload.snap" 2>/dev/null)" = "null" ] && ok "B: offload snapshot captured" || no "B: bad snapshot"
touch "$STATE/tether_force_on"
sleep 3
if applied; then ok "B: applied on force"; else no "B: not applied on force"; fi
if [ "$(offload)" = "1" ]; then ok "B: offload disabled"; else no "B: offload=$(offload)"; fi
if v4jump; then ok "B: v4 FORWARD jump pinned"; else no "B: no v4 jump"; fi
if v6jump; then ok "B: v6 FORWARD jump pinned"; else no "B: no v6 jump"; fi
if [ "$(v4rules)" = "1" ]; then ok "B: single REJECT rule (no rebuild loop)"; else no "B: v4 rules=$(v4rules)"; fi
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
rm -f "$STATE/tether_force_on"; sleep 3
applied && no "C: still applied" || ok "C: reverted on force-clear"
[ "$(offload)" = "<unset>" ] && ok "C: offload restored" || no "C: offload=$(offload)"
v4jump && no "C: v4 jump remains" || ok "C: v4 chain torn down"
rm -f "$STATE/tether_force_on"
sleep 3
if applied; then no "C: still applied"; else ok "C: reverted on force-clear"; fi
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
echo focus > "$STATE/current_mode.txt"; sleep 3
applied && ok "D: applied at home (focus)" || no "D: not applied at home"
v4jump && ok "D: v4 jump pinned at home" || no "D: no v4 jump at home"
echo focus >"$STATE/current_mode.txt"
sleep 3
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
touch "$STATE/tether_override"; sleep 3
applied && no "E: applied despite override" || ok "E: suspended by override"
v4jump && no "E: v4 jump despite override" || ok "E: torn down by override"
touch "$STATE/tether_override"
sleep 3
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"
[ "$FAIL" -eq 0 ]