#!/bin/sh
#
# clean-all.sh -- reclaim disk space from browser and application caches
# in the current user's home directory.
#
# Only regenerable cache data is removed. Nothing is ever deleted from
# system paths, and nothing outside $HOME is touched. No sudo required.
#
# PRESERVED (never removed):
#   ~/.local/state            application state, not cache
#   ~/.local/share            installed apps, extensions, flatpaks
#   ~/.vscode/extensions      installed editor extensions
#   ~/.var/app/*/config,data  installed application data
#   Browser profile databases: IndexedDB, Local Storage, WebStorage
#   Browser data files: Cookies, History, Login Data, Bookmarks,
#   Preferences, Sessions, Extensions, Safe Browsing, Widevine DRM
#
# APPS THAT ARE RUNNING ARE SKIPPED, because deleting cache files
# underneath a live process can corrupt its profile.
#
# Written for POSIX sh: no arrays, no [[ ]], no bash extensions.
#
set -eu

DRY_RUN=0
ASSUME_YES=0
BROWSERS_ONLY=0

PROG=${0##*/}

TAB=$(printf '\t')
NL='
'
DEFAULT_IFS=$IFS

usage() {
	cat <<-EOF
		$PROG -- reclaim disk space from browser and app caches in \$HOME

		USAGE
		    $PROG [OPTIONS]

		OPTIONS
		    -n, --dry-run    List what would be deleted, delete nothing
		    -y, --yes        Skip the confirmation prompt
		    -b, --browsers   Clean browser caches only, skip app caches
		    -h, --help       Show this help

		SCOPE
		    XDG cache tree (\$HOME/.cache), browser profile caches for
		    Chrome, Chromium, Brave, Vivaldi, Edge and Firefox, VS Code
		    and other Electron applications, flatpak application caches,
		    and the npm download cache.

		SAFETY
		    - Applications detected as running are skipped, with a warning.
		    - Installed extensions, application data and browser profile
		      databases are never touched.
		    - Installs itself nowhere, requires no root, and touches
		      nothing outside \$HOME.
		    - The first launch of each cleaned application is slower and
		      uses extra bandwidth while caches and models are rebuilt.

		EXAMPLES
		    $PROG --dry-run     Preview everything without deleting
		    $PROG --browsers    Only the browser caches
		    $PROG --yes         Clean everything unattended
	EOF
}

while [ $# -gt 0 ]; do
	case $1 in
	-n | --dry-run)
		DRY_RUN=1
		shift
		;;
	-y | --yes)
		ASSUME_YES=1
		shift
		;;
	-b | --browsers)
		BROWSERS_ONLY=1
		shift
		;;
	-h | --help)
		usage
		exit 0
		;;
	*)
		printf '%s: unknown option: %s\n\n' "$PROG" "$1" >&2
		usage >&2
		exit 2
		;;
	esac
done

# ---------------------------------------------------------------- output ---

if [ -t 1 ]; then
	BOLD=$(printf '\033[1m')
	GREEN=$(printf '\033[32m')
	YELLOW=$(printf '\033[33m')
	CYAN=$(printf '\033[36m')
	DIM=$(printf '\033[2m')
	OFF=$(printf '\033[0m')
else
	BOLD=''
	GREEN=''
	YELLOW=''
	CYAN=''
	DIM=''
	OFF=''
fi

warn() {
	printf '%swarning:%s %s\n' "$YELLOW" "$OFF" "$*" >&2
}

# ------------------------------------------------------------- utilities ---

# Convert a byte count to a short human readable string.
human() {
	awk -v b="${1:-0}" 'BEGIN {
		split("B KB MB GB TB", u, " ")
		i = 1
		while (b >= 1024 && i < 5) { b /= 1024; i++ }
		if (i == 1) printf "%d %s", b, u[i]
		else printf "%.1f %s", b, u[i]
	}'
}

# Size of a path in bytes, or 0. du -sk keeps this portable; du -b is a
# GNU extension.
path_bytes() {
	if [ -e "$1" ]; then
		du -sk -- "$1" 2>/dev/null | awk '{ print $1 * 1024 }'
	else
		printf '0\n'
	fi
}

# Free space on the filesystem holding $HOME, in bytes.
free_bytes() {
	df -kP "$HOME" 2>/dev/null | awk 'NR == 2 { print $4 * 1024 }'
}

# Snapshot of every running process, built exactly once per run.
#
# Scanning /proc for each individual check would dominate the runtime, so
# the index is collected up front and every later lookup becomes a plain
# string match with no subprocesses at all.
PROCESS_INDEX=''

build_process_index() {
	index=''
	for proc in /proc/[0-9]*; do
		[ -d "$proc" ] || continue
		exe=$(readlink -f "$proc/exe" 2>/dev/null || true)
		comm=''
		# read is a shell builtin, so this costs no fork.
		IFS= read -r comm <"$proc/comm" 2>/dev/null || comm=''
		index="$index $exe $comm"
	done
	PROCESS_INDEX=$(printf '%s' "$index" | tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz')
	return 0
}

# True when $1 appears anywhere in the process index.
#
# Both the resolved binary path and the kernel process name are indexed,
# because neither is sufficient alone: reading /proc/PID/exe returns
# empty for processes we cannot inspect and for some Plasma components,
# while the process name alone cannot distinguish two programs sharing a
# name. Matching is case insensitive so a config directory called "Code"
# matches a binary installed as ".../code".
#
# The pattern is deliberately tested for containment rather than equality.
# A case statement matches the whole word, so testing the index directly
# would only ever succeed for a pattern spanning every process on the
# system. Wrapping it in wildcards turns the index into the "contains"
# test it is meant to be.
has_running_process() {
	[ -n "$PROCESS_INDEX" ] || return 1
	want=$(printf '%s' "$1" | tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz')
	# shellcheck disable=SC2254
	case $PROCESS_INDEX in
	*$want*) return 0 ;;
	esac
	return 1
}

# True when any of the space separated process patterns in $1 matches a
# running executable.
running_any() {
	for want in $1; do
		if has_running_process "$want"; then
			return 0
		fi
	done
	return 1
}

# True when the Chromium or Electron application storing its profile in $1
# is running. Every Chromium fork, including Electron apps and Microsoft
# Edge, creates a SingletonLock symlink inside its user-data directory
# whose target encodes the pid of the live instance. Consulting it detects
# each browser by its own bookkeeping, which stays correct even when the
# binary name has nothing to do with the directory name -- Edge installs
# to .../msedge/msedge yet profiles in ~/.config/microsoft-edge, so
# matching on names alone silently fails.
singleton_running() {
	cfg=$1
	lock="$cfg/SingletonLock"
	[ -L "$lock" ] || return 1

	target=$(readlink "$lock" 2>/dev/null) || return 1
	pid=${target##*-}
	case $pid in
	'' | *[!0-9]*) return 1 ;;
	esac

	[ -d "/proc/$pid" ] || return 1
	return 0
}

TARGETS=$(mktemp) || {
	printf '%s: cannot create temporary file\n' "$PROG" >&2
	exit 1
}
LISTING=$(mktemp) || {
	rm -f "$TARGETS"
	printf '%s: cannot create temporary file\n' "$PROG" >&2
	exit 1
}
trap 'rm -f "$TARGETS" "$LISTING"' EXIT
trap 'rm -f "$TARGETS" "$LISTING"; exit 130' HUP INT TERM

# Record a path for cleaning. Newline separated, so spaces are safe.
# Duplicates are dropped, since several scanners can reach the same path.
add() {
	if [ -n "${1:-}" ] && [ -e "$1" ]; then
		if ! grep -F -x -q -- "$1" "$TARGETS" 2>/dev/null; then
			printf '%s\n' "$1" >>"$TARGETS"
		fi
	fi
}

# Record every child of a directory, keeping the directory itself so that
# the owning application finds the permissions and ownership it expects.
# Pure shell globbing: "$1"/* plus the two dotfile patterns, which
# excludes "." and "..".
add_contents() {
	parent=$1
	[ -d "$parent" ] || return 0
	for entry in "$parent"/* "$parent"/.[!.]* "$parent"/..?*; do
		[ -e "$entry" ] || [ -L "$entry" ] || continue
		add "$entry"
	done
	return 0
}

# ---------------------------------------------------------------- caches ---

# Cache directories found inside a single application profile.
PROFILE_DIRS='Cache
Code Cache
GPUCache
ShaderCache
GrShaderCache
GPUPersistentCache
GraphiteDawnCache
DawnCache
DawnGraphiteCache
DawnWebGPUCache
CachedData
CachedExtensionVSIXs
CachedProfilesData
CachedExtensions
Service Worker/CacheStorage
Service Worker/ScriptCache
Service Worker/Database
Shared Dictionary
blob_storage
logs'

# Cache directories found directly under an application root.
APP_DIRS='Cache
Code Cache
GPUCache
ShaderCache
GrShaderCache
GPUPersistentCache
CachedData
CachedExtensionVSIXs
CachedProfilesData
CachedExtensions
DawnCache
DawnGraphiteCache
DawnWebGPUCache
blob_storage
logs
cache'

# Chromium caches and re-downloadable model stores shared by all profiles.
BROWSER_DIRS='component_crx_cache
extensions_crx_cache
GrShaderCache
ShaderCache
GraphiteDawnCache
GPUPersistentCache
optimization_guide_model_store
OptimizationGuideModelStore
screen_ai
WasmTtsEngine
OnDeviceHeadSuggestModel'

# --------------------------------------------------------------- browsers ---

clean_chromium() {
	label=$1
	cfg=$2
	patterns=$3
	found=0

	[ -d "$cfg" ] || return 0

	if singleton_running "$cfg" || running_any "$patterns"; then
		warn "$label is running -- skipped (close it to clean its cache)"
		return 0
	fi

	IFS=$NL
	for dir in $BROWSER_DIRS; do
		if [ -e "$cfg/$dir" ]; then
			add "$cfg/$dir"
			found=1
		fi
	done

	for profile in "$cfg"/Default "$cfg"/Profile\ *; do
		[ -d "$profile" ] || continue
		for dir in $PROFILE_DIRS; do
			if [ -e "$profile/$dir" ]; then
				add "$profile/$dir"
				found=1
			fi
		done
	done
	IFS=$DEFAULT_IFS

	# The separate cache tree holds only disposable data.
	add_contents "$HOME/.cache/$label"

	if [ "$found" -eq 1 ]; then
		printf '%sfound%s %s profile data in %s\n' "$BOLD" "$OFF" "$label" "$cfg"
	fi
	return 0
}

clean_firefox() {
	found=0

	[ -d "$HOME/.mozilla/firefox" ] || return 0

	if has_running_process '*/firefox*'; then
		warn 'firefox is running -- skipped (close it to clean its cache)'
		return 0
	fi

	for profile in "$HOME"/.mozilla/firefox/*/; do
		[ -d "$profile" ] || continue
		for dir in cache2 startupCache shader-cache OfflineCache crashes; do
			if [ -e "$profile$dir" ]; then
				add "$profile$dir"
				found=1
			fi
		done
	done

	add_contents "$HOME/.cache/mozilla"
	add_contents "$HOME/.cache/firefox"

	if [ "$found" -eq 1 ]; then
		printf '%sfound%s firefox profile data in %s\n' "$BOLD" "$OFF" "$HOME/.mozilla/firefox"
	fi
	return 0
}

# ------------------------------------------------------------------- apps ---

# VS Code and similar Electron editors, whose download cache is
# surprisingly large and entirely disposable.
clean_electron_apps() {
	found=0

	for appdir in "$HOME"/.config/*/ "$HOME"/.var/app/*/; do
		[ -d "$appdir" ] || continue

		label=${appdir%/}
		label=${label##*/}
		[ -n "$label" ] || continue

		# Skip anything currently running. The singleton lock is checked
		# first, since it identifies Chromium and Electron apps by their
		# own bookkeeping rather than by a guessed binary name.
		if singleton_running "$appdir" || has_running_process "*/${label}*"; then
			continue
		fi

		IFS=$NL
		for dir in $APP_DIRS; do
			if [ -e "$appdir$dir" ]; then
				add "$appdir$dir"
				found=1
			fi
		done

		for profile in "$appdir"Default "$appdir"Profile\ *; do
			[ -d "$profile" ] || continue
			for dir in $PROFILE_DIRS; do
				if [ -e "$profile$dir" ]; then
					add "$profile$dir"
					found=1
				fi
			done
		done
		IFS=$DEFAULT_IFS
	done

	if [ "$found" -eq 1 ]; then
		printf '%sfound%s application caches in %s/.config and %s/.var/app\n' \
			"$BOLD" "$OFF" "$HOME" "$HOME"
	fi
	return 0
}

# Flatpak applications keep their own cache trees.
clean_flatpak_caches() {
	found=0
	for cachedir in "$HOME"/.var/app/*/cache "$HOME"/.var/app/*/.cache; do
		[ -d "$cachedir" ] || continue
		label=${cachedir%/}
		label=${label%/*}
		label=${label##*/}
		if has_running_process "*/${label}*"; then
			continue
		fi
		add_contents "$cachedir"
		found=1
	done
	if [ "$found" -eq 1 ]; then
		printf '%sfound%s flatpak application caches in %s/.var/app\n' "$BOLD" "$OFF" "$HOME"
	fi
	return 0
}

# Package manager download caches.
clean_package_managers() {
	if [ -d "$HOME/.npm/_cacache" ]; then
		add_contents "$HOME/.npm/_cacache"
		printf '%sfound%s npm download cache in %s/.npm/_cacache\n' "$BOLD" "$OFF" "$HOME"
	fi
	if [ -d "$HOME/.cargo/registry/cache" ]; then
		add_contents "$HOME/.cargo/registry/cache"
	fi
	return 0
}

# Cache entry name prefixes whose owning application must be checked
# before removal. Removing these out from under a live session is the one
# way cleaning a cache tree can still cause visible trouble: deleting the
# Plasma shell or theme cache makes the desktop re-render mid-session.
GUARDED_PREFIXES='plasma
kwin
ksycoca
kded
krunner
kscreenlocker
ksmserver
ksvg-elements
ksplash
google-chrome
google-chrome-stable
chromium
chromium-browser
brave
vivaldi
vivaldi-bin
microsoft-edge
msedge
opera
iridium
yandex-browser
firefox
firefox-esr
thunderbird
code
opencode'

# True when the application owning cache entry $1 is currently running.
# The process pattern is derived from the entry name, so "plasmashell"
# matches the running binary for the "plasma" prefix.
entry_running() {
	entry_name=$1
	IFS=$NL
	for prefix in $GUARDED_PREFIXES; do
		# The prefix is quoted to match literally, not as a glob.
		case $entry_name in
		"$prefix"*)
			IFS=$DEFAULT_IFS
			has_running_process "*/${prefix}*"
			return $?
			;;
		esac
	done
	IFS=$DEFAULT_IFS
	return 1
}

# The XDG cache tree. Defined by the specification as disposable, and
# rebuilt automatically by whichever application created each entry.
clean_xdg_cache() {
	[ -d "$HOME/.cache" ] || return 0

	found=0
	for entry in "$HOME"/.cache/* "$HOME"/.cache/.[!.]* "$HOME"/.cache/..?*; do
		[ -e "$entry" ] || [ -L "$entry" ] || continue

		name=${entry##*/}

		if entry_running "$name"; then
			warn "$name is in use -- skipped (close it to clean its cache)"
			continue
		fi

		add "$entry"
		found=1
	done

	if [ "$found" -eq 1 ]; then
		printf '%sfound%s XDG cache tree at %s/.cache\n' "$BOLD" "$OFF" "$HOME"
	fi
	return 0
}

# ------------------------------------------------------------------- main ---

printf '%scache cleanup%s\n\n' "$BOLD" "$OFF"

# One scan of /proc for the whole run; every running-application check
# below then resolves without touching the filesystem again.
build_process_index

clean_chromium google-chrome "$HOME/.config/google-chrome" \
	'*/google-chrome *chrome'
clean_chromium chromium "$HOME/.config/chromium" \
	'*/chromium */chromium-browser *chrome'
clean_chromium chromium-browser "$HOME/.config/chromium-browser" \
	'*/chromium */chromium-browser *chrome'
clean_chromium brave "$HOME/.config/BraveSoftware/Brave-Browser" \
	'*/brave *brave-browser'
clean_chromium vivaldi "$HOME/.config/vivaldi" \
	'*/vivaldi *vivaldi-bin'
clean_chromium microsoft-edge "$HOME/.config/microsoft-edge" \
	'*/msedge *microsoft-edge'
clean_chromium opera "$HOME/.config/opera" \
	'*/opera'
clean_chromium iridium "$HOME/.config/iridium" \
	'*/iridium'
clean_chromium yandex-browser "$HOME/.config/yandex-browser" \
	'*/yandex-browser *browser'
clean_firefox

if [ "$BROWSERS_ONLY" -ne 1 ]; then
	clean_electron_apps
	clean_flatpak_caches
	clean_package_managers
	clean_xdg_cache
fi

# Measure each target once, writing "bytes<TAB>path" records. The total
# and the display listing are both derived from this single pass.
total=0
count=0
while IFS= read -r path; do
	[ -n "$path" ] || continue
	bytes=$(path_bytes "$path")
	bytes=${bytes:-0}
	printf '%s\t%s\n' "$bytes" "$path" >>"$LISTING"
	total=$((total + bytes))
	count=$((count + 1))
done <"$TARGETS"

if [ "$count" -eq 0 ] || [ "$total" -eq 0 ]; then
	printf '\nNothing to clean -- no cached data found.\n'
	exit 0
fi

printf '\n%sWill free approximately %s across %s path(s)%s\n' \
	"$CYAN" "$(human "$total")" "$count" "$OFF"
if [ "$DRY_RUN" -eq 1 ]; then
	printf '%s--- all targets (full list) ---%s\n\n' "$DIM" "$OFF"
else
	printf '%s--- largest targets ---%s\n\n' "$DIM" "$OFF"
fi
if [ "$DRY_RUN" -eq 1 ]; then
	sort -rn "$LISTING"
else
	sort -rn "$LISTING" | head -20
fi | while IFS="$TAB" read -r bytes path; do
	printf '  %8s  %s\n' "$(human "$bytes")" "$path"
done

if [ "$DRY_RUN" -eq 1 ]; then
	printf '\n%sDry run -- nothing was deleted.%s\n' "$YELLOW" "$OFF"
	exit 0
fi

if [ "$DRY_RUN" -eq 1 ]; then
	printf '\n%sDry run -- nothing was deleted.%s\n' "$YELLOW" "$OFF"
	exit 0
fi

if [ "$ASSUME_YES" -ne 1 ]; then
	printf '\nProceed? [y/N] '
	reply=''
	IFS= read -r reply || reply=''
	case $reply in
	[yY] | [yY][eE][sS]) ;;
	*)
		printf 'Aborted.\n'
		exit 0
		;;
	esac
fi

before=$(free_bytes)
before=${before:-0}
failed=0
removed=0

while IFS= read -r path; do
	[ -n "$path" ] || continue
	if rm -rf -- "$path" 2>/dev/null; then
		removed=$((removed + 1))
		printf '%s  removed%s  %s\n' "$GREEN" "$OFF" "$path"
	else
		warn "could not remove $path"
		failed=$((failed + 1))
	fi
done <"$TARGETS"

after=$(free_bytes)
after=${after:-0}
freed=$((after - before))

printf '\n%sRemoved %s path(s), reclaiming %s on disk%s\n' \
	"$BOLD" "$removed" "$(human "$freed")" "$OFF"
printf '%spreserved: cookies, passwords, history, bookmarks, extensions,%s\n' "$DIM" "$OFF"
printf '%s           IndexedDB, Local Storage, Safe Browsing, DRM, %s\n' "$DIM" "$OFF"
printf '%s           ~/.vscode/extensions, ~/.local/state, ~/.local/share%s\n' "$DIM" "$OFF"

half=$((total / 2))
if [ "$freed" -lt "$half" ]; then
	printf '\n%sNote:%s freed less than the estimate, which usually means\n' "$YELLOW" "$OFF"
	printf '      another process was writing cache data at the same time.\n'
fi

if [ "$failed" -gt 0 ]; then
	printf '\n%d path(s) could not be removed.\n' "$failed"
fi

exit 0