#!/bin/sh if ! command -v bash >/dev/null 2>&1; then echo "EzClean installer requires bash, but bash was not found." >&2 exit 1 fi exec bash -s -- "$@" <<'EZCLEAN_BASH_SCRIPT' #!/usr/bin/env bash # set -u: catch unbound variables (defense in depth for a tool that runs rm). # set -o pipefail: surface failures in du|awk style pipelines. # Deliberately NOT 'set -e': this script uses many "(( cond )) && action" # statements that legitimately return non-zero, which set -e would treat as # fatal. The real empty-$HOME data-loss vector is handled by explicit # validation below, and every deletion goes through is_safe_to_delete(). set -u set -o pipefail APP_NAME="EzClean" VERSION="1.0.0" # Short commit/build stamp, substituted at release time (see scripts/release.sh). # Stays "dev" for un-released/local copies so users can tell what they ran. BUILD_REF="dev" GREEN="\033[0;32m" YELLOW="\033[1;33m" RED="\033[0;31m" BLUE="\033[0;34m" CYAN="\033[0;36m" BOLD="\033[1m" DIM="\033[2m" RESET="\033[0m" HOME_DIR="${HOME}" MIN_BIG_FOLDER_KB="${MIN_BIG_FOLDER_KB:-1048576}" # 1GB # Per-path du timeout (seconds). A cloud-synced (iCloud Drive) or network-mounted # directory can make `du` hang indefinitely; bounding each measurement keeps the # scan responsive. Override with EZCLEAN_DU_TIMEOUT. perl's alarm+exec is used # when available (zero latency on fast paths, no orphaned processes); if perl is # missing we fall back to plain du. SIZE_TIMEOUT="${EZCLEAN_DU_TIMEOUT:-20}" HAVE_PERL=false command -v perl >/dev/null 2>&1 && HAVE_PERL=true # OS detection drives the rule pack (which paths to scan) and the Trash # location. The engine — sizing, the containment guard, the checklist, the # disposition logic — is identical across OSes; only the data differs. This is # the cheap, reversible Phase-1 proof of the rule-pack abstraction (see # docs/adr/0001-cross-platform-strategy.md) before any Go/Windows commitment. # EZCLEAN_OS overrides detection (used by the test suite to exercise the Linux # rule pack on a macOS CI host, and as an escape hatch on exotic systems). if [ -n "${EZCLEAN_OS:-}" ]; then OS="$EZCLEAN_OS" else case "$(uname -s 2>/dev/null)" in Darwin) OS="macos" ;; Linux) OS="linux" ;; *) OS="unknown" ;; esac fi # Trash destination for the default (recoverable) disposition. # macOS: ~/.Trash (Finder's trash) # Linux: ~/.local/share/Trash/files (freedesktop.org trash spec) if [ "$OS" = "linux" ]; then TRASH_DIR="${XDG_DATA_HOME:-$HOME_DIR/.local/share}/Trash/files" else TRASH_DIR="$HOME_DIR/.Trash" fi # Every destructive path is built from $HOME, so an empty or relative HOME would # aim deletions at the wrong place (e.g. "" -> "/Library/Caches"). Refuse to run # unless HOME is a real, absolute directory. case "$HOME_DIR" in /*) ;; *) echo "EzClean: \$HOME is not an absolute path ('$HOME_DIR'). Refusing to run." >&2 exit 1 ;; esac if [ ! -d "$HOME_DIR" ]; then echo "EzClean: \$HOME ('$HOME_DIR') is not a directory. Refusing to run." >&2 exit 1 fi MODE="fast" MODE_WAS_SET=false ACTION="interactive" # Deletion behavior: # HARD_DELETE=false -> move selected items to ~/.Trash (reversible, default) # HARD_DELETE=true -> permanent rm -rf (opt-in via --hard) # DRY_RUN=true -> never touch the filesystem; print exactly what would go HARD_DELETE=false DRY_RUN=false # EzClean AI: an on-device recommendation pass. When true, the delete menu opens # with the safe-to-reclaim items pre-ticked (all `safe` + clearly-regenerable # `review` build/dependency caches), leaving personal data un-ticked. The menu # stays fully editable and nothing is removed until you confirm — AI only sets # the starting selection. No network, no model download; pure local rules. AI_MODE=false CANDIDATE_RISKS=() CANDIDATE_NAMES=() CANDIDATE_PATHS=() CANDIDATE_KBS=() for arg in "$@"; do case "$arg" in scan) ACTION="scan" ;; --deep|--full) MODE="deep"; MODE_WAS_SET=true ;; --fast) MODE="fast"; MODE_WAS_SET=true ;; --hard) HARD_DELETE=true ;; --ai|--smart) AI_MODE=true ;; --dry-run|--print-plan) DRY_RUN=true ;; -h|--help|help) echo "EzClean ${VERSION} (${BUILD_REF})" echo "" echo "Usage:" echo " curl -fsSL https://ezclean.xyz/install.sh | sh # interactive" echo " curl -fsSL https://ezclean.xyz/install.sh | sh -s -- --full # deep + interactive" echo " curl -fsSL https://ezclean.xyz/install.sh | sh -s -- scan --fast # scan only" echo "" echo "Flags:" echo " --fast Common cache/log/dev folders (default, seconds)." echo " --deep,--full Adds large-file/home sweeps. Slower." echo " scan Scan and report only. Never deletes." echo " --dry-run Print exactly what would be removed, then exit. No writes." echo " --hard Permanently rm selected items instead of moving to Trash." echo " --ai, --smart EzClean AI: open the menu with safe-to-reclaim items pre-ticked." echo " (On-device, no network. You still review and confirm.)" echo " -h, --help Show this help." echo "" echo "Environment:" echo " EZCLEAN_DU_TIMEOUT Per-path size timeout in seconds (default 20)." echo " EZCLEAN_OS Override OS detection: macos | linux." echo "" echo "Note: 'scan' only reports — it never deletes, regardless of --hard/--dry-run." echo "On Windows, use: irm https://ezclean.xyz/install.ps1 | iex" exit 0 ;; esac done line() { echo "────────────────────────────────────────────────────────────" } section() { echo "" echo -e "${BOLD}$1${RESET}" line } info() { echo -e "${DIM}→ $1${RESET}" } done_msg() { echo -e "${GREEN}✓ $1${RESET}" } warn() { echo -e "${YELLOW}! $1${RESET}" } mode_label() { if [[ "$MODE" == "deep" ]]; then echo "full" else echo "fast" fi } header_mode_label() { if [[ "$MODE_WAS_SET" == "true" ]]; then mode_label else echo "choose below" fi } # Disk usage in KB for one path, bounded by SIZE_TIMEOUT so a cloud-synced # (iCloud Drive) or network-mounted directory can't hang the whole scan. Uses # -x so du never crosses into another filesystem while sizing a subtree. perl's # alarm+exec gives a zero-latency timeout with no orphaned process; without perl # it degrades to a plain du. du_sk() { local path="$1" if [[ "$HAVE_PERL" == "true" ]]; then perl -e 'alarm shift @ARGV; exec @ARGV' "$SIZE_TIMEOUT" du -skx "$path" 2>/dev/null else du -skx "$path" 2>/dev/null fi } size_kb() { local path="$1" if [[ ! -e "$path" ]]; then echo 0 return 0 fi local out out="$(du_sk "$path" | awk '{print $1}')" if [[ -z "${out:-}" ]]; then echo 0 else echo "$out" fi return 0 } human_size() { local kb="${1:-0}" # LC_ALL=C so the decimal separator is always "." — some Mac locales render # "1,5 GB" otherwise, which is wrong and breaks downstream parsing. LC_ALL=C awk -v kb="$kb" ' BEGIN { if (kb >= 1024*1024) printf "%.1f GB", kb/1024/1024; else if (kb >= 1024) printf "%.1f MB", kb/1024; else printf "%d KB", kb; } ' } print_item() { local risk="$1" local name="$2" local path="$3" local kb="$4" local color="$RESET" case "$risk" in safe) color="$GREEN" ;; review) color="$YELLOW" ;; danger) color="$RED" ;; *) color="$RESET" ;; esac printf "%b%-9s%b %-30s %12s %s\n" "$color" "$risk" "$RESET" "$name" "$(human_size "$kb")" "$path" } add_candidate() { local risk="$1" local name="$2" local path="$3" local kb="$4" [[ "$risk" == "danger" ]] && return 0 [[ -e "$path" ]] || return 0 case "$path" in "$HOME_DIR"/*|"$HOME_DIR") ;; *) return 0 ;; esac case "$path" in "$HOME_DIR"|"$HOME_DIR/Desktop"|"$HOME_DIR/Documents"|"$HOME_DIR/Downloads"|"$HOME_DIR/Movies"|"$HOME_DIR/Pictures") return 0 ;; esac # Guard the expansion: on bash 3.2, "${arr[@]}" on an empty array trips set -u. local existing if (( ${#CANDIDATE_PATHS[@]} > 0 )); then for existing in "${CANDIDATE_PATHS[@]}"; do [[ "$existing" == "$path" ]] && return 0 done fi CANDIDATE_RISKS+=("$risk") CANDIDATE_NAMES+=("$name") CANDIDATE_PATHS+=("$path") CANDIDATE_KBS+=("$kb") } scan_path() { local risk="$1" local name="$2" local path="$3" if [[ -e "$path" ]]; then local kb kb="$(size_kb "$path")" if [[ "${kb:-0}" -gt 0 ]]; then print_item "$risk" "$name" "$path" "$kb" add_candidate "$risk" "$name" "$path" "$kb" fi fi } header() { clear 2>/dev/null || true echo "" echo -e "${CYAN}╭────────────────────────────────────────────────────────────╮${RESET}" printf "%b│%b %-56s %b│%b\n" "$CYAN" "$RESET" "${APP_NAME} ${VERSION}" "$CYAN" "$RESET" echo -e "${CYAN}├────────────────────────────────────────────────────────────┤${RESET}" printf "%b│%b %-56s %b│%b\n" "$CYAN" "$RESET" "Clean space safely. Review findings, then choose." "$CYAN" "$RESET" printf "%b│%b %-56s %b│%b\n" "$CYAN" "$RESET" "Nothing is removed until you confirm." "$CYAN" "$RESET" echo -e "${CYAN}├────────────────────────────────────────────────────────────┤${RESET}" printf "%b│%b %-56s %b│%b\n" "$CYAN" "$RESET" "Scan mode: $(header_mode_label)" "$CYAN" "$RESET" printf "%b│%b %-56s %b│%b\n" "$CYAN" "$RESET" "Developed by minhle.xyz" "$CYAN" "$RESET" echo -e "${CYAN}╰────────────────────────────────────────────────────────────╯${RESET}" echo "" } # Rule packs are split by OS. The engine (sizing, guard, checklist, delete) is # identical; only these path lists differ. macOS keeps its exact original rules; # Linux uses XDG locations (~/.cache, ~/.config, ~/.local/share). Package-manager # caches that live in dotdirs (~/.npm, ~/.cargo, ~/.gradle, ~/.m2, ~/.nuget, # ~/go) are cross-platform and scanned in both. See docs/adr/0001. XDG_CACHE="${XDG_CACHE_HOME:-$HOME_DIR/.cache}" XDG_CONFIG="${XDG_CONFIG_HOME:-$HOME_DIR/.config}" XDG_DATA="${XDG_DATA_HOME:-$HOME_DIR/.local/share}" scan_core_macos() { scan_path safe "User cache" "$HOME_DIR/Library/Caches" scan_path safe "User logs" "$HOME_DIR/Library/Logs" scan_path safe "Trash" "$HOME_DIR/.Trash" scan_path safe "Chrome cache" "$HOME_DIR/Library/Caches/Google/Chrome" scan_path safe "Brave cache" "$HOME_DIR/Library/Caches/BraveSoftware" scan_path safe "Firefox cache" "$HOME_DIR/Library/Caches/Firefox" scan_path safe "Arc cache" "$HOME_DIR/Library/Caches/Arc" scan_path safe "Safari cache" "$HOME_DIR/Library/Containers/com.apple.Safari/Data/Library/Caches" scan_path safe "Homebrew cache" "$HOME_DIR/Library/Caches/Homebrew" scan_path safe "Yarn cache" "$HOME_DIR/Library/Caches/Yarn" scan_path safe "pnpm store" "$HOME_DIR/Library/pnpm/store" scan_path safe "pip cache" "$HOME_DIR/Library/Caches/pip" scan_path safe "Poetry cache" "$HOME_DIR/Library/Caches/pypoetry" scan_path safe "Go build cache" "$HOME_DIR/Library/Caches/go-build" scan_path safe "Xcode DerivedData" "$HOME_DIR/Library/Developer/Xcode/DerivedData" } scan_core_linux() { scan_path safe "User cache (XDG)" "$XDG_CACHE" scan_path safe "Trash" "$XDG_DATA/Trash" scan_path safe "Chrome cache" "$XDG_CACHE/google-chrome" scan_path safe "Chromium cache" "$XDG_CACHE/chromium" scan_path safe "Brave cache" "$XDG_CACHE/BraveSoftware" scan_path safe "Firefox cache" "$XDG_CACHE/mozilla/firefox" scan_path safe "pip cache" "$XDG_CACHE/pip" scan_path safe "Go build cache" "$XDG_CACHE/go-build" scan_path safe "Thumbnail cache" "$XDG_CACHE/thumbnails" scan_path safe "fontconfig cache" "$XDG_CACHE/fontconfig" scan_path safe "Flatpak cache" "$XDG_DATA/flatpak/.removed" } scan_core() { section "SAFE / GENERATED DATA" info "Checking known cache/log/package-manager folders..." if [ "$OS" = "linux" ]; then scan_core_linux else scan_core_macos fi # Cross-platform package-manager caches (dotdirs in $HOME on every OS). scan_path safe "npm cache" "$HOME_DIR/.npm" scan_path safe "Cargo cache" "$HOME_DIR/.cargo/registry/cache" scan_path safe "Go module cache" "$HOME_DIR/go/pkg/mod/cache" scan_path safe "Gradle cache" "$HOME_DIR/.gradle/caches" scan_path safe "Gradle dists" "$HOME_DIR/.gradle/wrapper/dists" scan_path safe "NuGet HTTP cache" "$HOME_DIR/.local/share/NuGet/v3-cache" done_msg "Safe/generated scan complete" } scan_review_macos() { scan_path review "iPhone backups" "$HOME_DIR/Library/Application Support/MobileSync/Backup" scan_path review "Docker data" "$HOME_DIR/Library/Containers/com.docker.docker" scan_path review "Docker app data" "$HOME_DIR/Library/Group Containers/group.com.docker" scan_path review "Xcode Archives" "$HOME_DIR/Library/Developer/Xcode/Archives" scan_path review "iOS DeviceSupport" "$HOME_DIR/Library/Developer/Xcode/iOS DeviceSupport" scan_path review "iOS Simulators" "$HOME_DIR/Library/Developer/CoreSimulator" scan_path review "Android SDK" "$HOME_DIR/Library/Android/sdk" scan_path review "Android Studio" "$HOME_DIR/Library/Application Support/Google/AndroidStudio" scan_path review "Adobe common" "$HOME_DIR/Library/Application Support/Adobe/Common" scan_path review "Adobe caches" "$HOME_DIR/Library/Caches/Adobe" scan_path review "DaVinci Resolve" "$HOME_DIR/Library/Application Support/Blackmagic Design/DaVinci Resolve" scan_path review "Movies folder" "$HOME_DIR/Movies" scan_path review "Slack data" "$HOME_DIR/Library/Application Support/Slack" scan_path review "Discord data" "$HOME_DIR/Library/Application Support/discord" scan_path review "Zoom recordings" "$HOME_DIR/Documents/Zoom" } scan_review_linux() { scan_path review "Docker data" "$XDG_DATA/docker" scan_path review "containerd data" "$HOME_DIR/.local/share/containerd" scan_path review "Android SDK" "$HOME_DIR/Android/Sdk" scan_path review "Android Studio" "$XDG_CONFIG/Google/AndroidStudio" scan_path review "Snap data" "$HOME_DIR/snap" scan_path review "Flatpak app data" "$XDG_DATA/flatpak/app" scan_path review "Slack data" "$XDG_CONFIG/Slack" scan_path review "Discord data" "$XDG_CONFIG/discord" scan_path review "Videos folder" "$HOME_DIR/Videos" scan_path review "Steam library" "$XDG_DATA/Steam/steamapps" scan_path review "Trash (expired)" "$XDG_DATA/Trash/files" } scan_review() { section "REVIEW NEEDED" info "Checking downloads, dev tools, app data and media folders..." scan_path review "Downloads" "$HOME_DIR/Downloads" scan_path review "Desktop" "$HOME_DIR/Desktop" if [ "$OS" = "linux" ]; then scan_review_linux else scan_review_macos fi # Cross-platform developer caches. scan_path review "Android AVD" "$HOME_DIR/.android/avd" scan_path review "Maven repo" "$HOME_DIR/.m2/repository" scan_path review "NuGet packages" "$HOME_DIR/.nuget/packages" scan_path review ".NET folder" "$HOME_DIR/.dotnet" done_msg "Review scan complete" } scan_danger() { section "DANGER ZONE / PERSONAL DATA - LIST ONLY" info "Checking personal libraries and VM folders. These are never auto-cleaned." scan_path danger "Photos Library" "$HOME_DIR/Pictures/Photos Library.photoslibrary" scan_path danger "Messages attach." "$HOME_DIR/Library/Messages/Attachments" scan_path danger "Mail data" "$HOME_DIR/Library/Containers/com.apple.mail" scan_path danger "Parallels VMs" "$HOME_DIR/Parallels" scan_path danger "VirtualBox VMs" "$HOME_DIR/VirtualBox VMs" scan_path danger "VMware VMs" "$HOME_DIR/Documents/Virtual Machines.localized" scan_path danger "UTM VMs" "$HOME_DIR/Library/Containers/com.utmapp.UTM" done_msg "Danger-zone scan complete" } scan_installers_archives() { section "OLD INSTALLERS / ARCHIVES IN DOWNLOADS" info "Searching old .dmg, .pkg, .zip, .rar, .7z, .iso files in Downloads..." local downloads="$HOME_DIR/Downloads" if [[ ! -d "$downloads" ]]; then echo "No Downloads folder." return 0 fi local found=false while IFS= read -r -d '' file; do found=true local kb kb="$(size_kb "$file")" printf "%12s %s\n" "$(human_size "$kb")" "$file" add_candidate review "Old archive" "$file" "$kb" done < <( find "$downloads" \ -type f \ \( -iname "*.dmg" -o -iname "*.pkg" -o -iname "*.zip" -o -iname "*.rar" -o -iname "*.7z" -o -iname "*.iso" -o -iname "*.tar.gz" \) \ -mtime +7 \ -print0 2>/dev/null ) if [[ "$found" == false ]]; then echo "No old installer/archive files found." fi done_msg "Installer/archive scan complete" } scan_large_documents() { section "LARGE DOCUMENTS / OFFICE / DATA FILES" if [[ "$MODE" == "fast" ]]; then info "Fast mode: checking Desktop, Documents and Downloads only." local roots=("$HOME_DIR/Desktop" "$HOME_DIR/Documents" "$HOME_DIR/Downloads") else info "Deep mode: searching entire Home folder. This can take a while." local roots=("$HOME_DIR") fi local found=false for root in "${roots[@]}"; do [[ -d "$root" ]] || continue info "Scanning documents in $root" while IFS= read -r -d '' file; do found=true local kb kb="$(size_kb "$file")" printf "%12s %s\n" "$(human_size "$kb")" "$file" add_candidate review "Large document" "$file" "$kb" done < <( find "$root" \ -type f \ \( \ -iname "*.xlsx" -o -iname "*.xlsm" -o -iname "*.xlsb" -o -iname "*.xls" -o \ -iname "*.csv" -o -iname "*.numbers" -o -iname "*.pptx" -o -iname "*.key" -o \ -iname "*.docx" -o -iname "*.pdf" -o -iname "*.sql" -o -iname "*.sqlite" -o \ -iname "*.db" -o -iname "*.parquet" -o -iname "*.json" -o -iname "*.ndjson" \ \) \ -size +100M \ -not -path "$HOME_DIR/Library/*" \ -not -path "$HOME_DIR/.Trash/*" \ -print0 2>/dev/null ) done if [[ "$found" == false ]]; then echo "No large office/data files found." fi done_msg "Large document scan complete" } scan_creative_files() { section "CREATIVE / VIDEO / DESIGN FILES" if [[ "$MODE" == "fast" ]]; then info "Fast mode: checking Desktop, Documents, Downloads, Movies and Pictures." local roots=("$HOME_DIR/Desktop" "$HOME_DIR/Documents" "$HOME_DIR/Downloads" "$HOME_DIR/Movies" "$HOME_DIR/Pictures") else info "Deep mode: searching entire Home folder. This can take a while." local roots=("$HOME_DIR") fi local found=false for root in "${roots[@]}"; do [[ -d "$root" ]] || continue info "Scanning creative files in $root" while IFS= read -r -d '' file; do found=true local kb kb="$(size_kb "$file")" printf "%12s %s\n" "$(human_size "$kb")" "$file" add_candidate review "Creative file" "$file" "$kb" done < <( find "$root" \ -type f \ \( \ -iname "*.mov" -o -iname "*.mp4" -o -iname "*.mkv" -o -iname "*.avi" -o -iname "*.webm" -o \ -iname "*.prproj" -o -iname "*.aep" -o -iname "*.psd" -o -iname "*.ai" -o -iname "*.fig" -o \ -iname "*.sketch" -o -iname "*.drp" -o -iname "*.dra" -o -iname "*.logicx" \ \) \ -size +500M \ -not -path "$HOME_DIR/Library/*" \ -not -path "$HOME_DIR/.Trash/*" \ -print0 2>/dev/null ) done if [[ "$found" == false ]]; then echo "No large creative/video/design files found." fi done_msg "Creative file scan complete" } scan_node_modules() { section "NODE_MODULES FOLDERS" if [[ "$MODE" == "fast" ]]; then info "Fast mode: checking common project folders." local roots=("$HOME_DIR/Projects" "$HOME_DIR/Developer" "$HOME_DIR/Code" "$HOME_DIR/Sites" "$HOME_DIR/Documents" "$HOME_DIR/Desktop") else info "Deep mode: searching entire Home folder. This can take a while." local roots=("$HOME_DIR") fi local found=false for root in "${roots[@]}"; do [[ -d "$root" ]] || continue info "Scanning node_modules in $root" while IFS= read -r -d '' dir; do found=true local kb kb="$(size_kb "$dir")" printf "%12s %s\n" "$(human_size "$kb")" "$dir" add_candidate safe "node_modules" "$dir" "$kb" done < <( find "$root" \ -type d \ -name "node_modules" \ -not -path "$HOME_DIR/Library/*" \ -not -path "$HOME_DIR/.Trash/*" \ -prune \ -print0 2>/dev/null ) done if [[ "$found" == false ]]; then echo "No node_modules folders found." fi done_msg "node_modules scan complete" } scan_vm_images() { section "VM / DISK IMAGE FILES" if [[ "$MODE" == "fast" ]]; then info "Fast mode: checking common VM locations." local roots=("$HOME_DIR/Parallels" "$HOME_DIR/VirtualBox VMs" "$HOME_DIR/Documents" "$HOME_DIR/Downloads" "$HOME_DIR/Library/Containers/com.utmapp.UTM") else info "Deep mode: searching entire Home folder. This can take a while." local roots=("$HOME_DIR") fi local found=false for root in "${roots[@]}"; do [[ -d "$root" ]] || continue info "Scanning VM images in $root" while IFS= read -r -d '' item; do found=true local kb kb="$(size_kb "$item")" printf "%12s %s\n" "$(human_size "$kb")" "$item" add_candidate review "VM/disk image" "$item" "$kb" done < <( find "$root" \ \( \ -name "*.pvm" -o -name "*.utm" -o -name "*.vmwarevm" -o -name "*.vbox" -o \ -name "*.vdi" -o -name "*.vmdk" -o -name "*.qcow2" -o -name "*.iso" \ \) \ -not -path "$HOME_DIR/.Trash/*" \ -print0 2>/dev/null ) done if [[ "$found" == false ]]; then echo "No VM/disk image files found." fi done_msg "VM/disk image scan complete" } scan_unknown_big_library_folders() { section "UNKNOWN / THIRD-PARTY APP STORAGE > 1GB" info "Checking large top-level folders in Library. This reveals unknown apps." local roots=( "$HOME_DIR/Library/Application Support" "$HOME_DIR/Library/Containers" "$HOME_DIR/Library/Group Containers" "$HOME_DIR/Library/Caches" ) local found=false for root in "${roots[@]}"; do [[ -d "$root" ]] || continue info "Scanning $root" while IFS= read -r -d '' dir; do local kb kb="$(size_kb "$dir")" if [[ "${kb:-0}" -ge "$MIN_BIG_FOLDER_KB" ]]; then found=true printf "%12s %s\n" "$(human_size "$kb")" "$dir" add_candidate review "App storage" "$dir" "$kb" fi done < <( find "$root" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null ) done if [[ "$found" == false ]]; then echo "No unknown third-party app storage > 1GB found." fi done_msg "Unknown app storage scan complete" } scan_top_home_folders() { section "TOP HOME FOLDERS" info "Calculating top folders. This can take a little while." local tmp tmp="$(mktemp)" while IFS= read -r -d '' dir; do info "Sizing $dir" local kb kb="$(size_kb "$dir")" echo "$kb|$dir" >> "$tmp" done < <( find "$HOME_DIR" \ -mindepth 1 \ -maxdepth 2 \ -type d \ -not -path "$HOME_DIR/.Trash/*" \ -not -path "$HOME_DIR/Library/*/*" \ -print0 2>/dev/null ) sort -nr "$tmp" | head -30 | while IFS="|" read -r kb dir; do if [[ "${kb:-0}" -gt 0 ]]; then printf "%12s %s\n" "$(human_size "$kb")" "$dir" fi done rm -f "$tmp" done_msg "Top home folders scan complete" } scan_apps() { section "INSTALLED APPS" info "Listing installed apps in /Applications and ~/Applications." local found=false for root in "/Applications" "$HOME_DIR/Applications"; do [[ -d "$root" ]] || continue info "Scanning apps in $root" while IFS= read -r -d '' app; do found=true local kb kb="$(size_kb "$app")" printf "%12s %s\n" "$(human_size "$kb")" "$app" done < <( find "$root" -maxdepth 1 -name "*.app" -print0 2>/dev/null ) done if [[ "$found" == false ]]; then echo "No user-installed apps found." fi done_msg "Installed apps scan complete" } ask_yes_no() { local prompt="$1" local answer if ! { exec 9<> /dev/tty; } 2>/dev/null; then warn "No interactive terminal found." return 1 fi while true; do printf "%b?%b %b%s%b %b[y/N]:%b " "$CYAN" "$RESET" "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET" >&9 if ! IFS= read -r answer <&9; then exec 9>&- return 1 fi case "$answer" in y|Y|yes|YES|Yes) exec 9>&- return 0 ;; n|N|no|NO|No|"") exec 9>&- return 1 ;; *) echo "Please answer yes or no." >&9 ;; esac done } choose_scan_mode() { local cursor=0 local key if [[ "$MODE_WAS_SET" == "true" ]]; then return 0 fi if ! { exec 9<> /dev/tty; } 2>/dev/null; then warn "No interactive terminal found." return 1 fi while true; do draw_scan_mode_menu "$cursor" >&9 key="$(read_menu_key)" || { exec 9>&- return 1 } case "$key" in $'\x1b[A'|$'\x1b[D'|k|h) cursor=0 ;; $'\x1b[B'|$'\x1b[C'|j|l) cursor=1 ;; 1) MODE="fast" MODE_WAS_SET=true exec 9>&- return 0 ;; 2) MODE="deep" MODE_WAS_SET=true exec 9>&- return 0 ;; ""|" "|$'\n'|$'\r') if (( cursor == 0 )); then MODE="fast" else MODE="deep" fi MODE_WAS_SET=true exec 9>&- return 0 ;; q|Q) exec 9>&- return 1 ;; esac done } draw_scan_mode_menu() { local cursor="$1" local fast_border="$CYAN" local full_border="$CYAN" local fast_mark=" " local full_mark=" " local current_title="Fast scan" local current_note="Common cleanup spots. Quicker, recommended." if (( cursor == 0 )); then fast_border="$GREEN" fast_mark="x" else full_border="$GREEN" full_mark="x" current_title="Full scan" current_note="Deeper home scan. Slower, more complete." fi { printf "\033[H\033[J" echo "" echo -e "${CYAN}╭────────────────────────────────────────────────────────────╮${RESET}" printf "%b│%b %-56s %b│%b\n" "$CYAN" "$RESET" "${APP_NAME} ${VERSION}" "$CYAN" "$RESET" printf "%b│%b %-56s %b│%b\n" "$CYAN" "$RESET" "Developed by minhle.xyz" "$CYAN" "$RESET" echo -e "${CYAN}╰────────────────────────────────────────────────────────────╯${RESET}" echo "" echo -e "${BOLD}Choose Scan Mode${RESET}" echo -e "${DIM}Use ←/→ or ↑/↓ to move. Press Enter to continue.${RESET}" echo "" echo -e "${fast_border}╭──────────────────────────╮${RESET} ${full_border}╭──────────────────────────╮${RESET}" printf "%b│%b [%s] %-18s %b│%b %b│%b [%s] %-18s %b│%b\n" \ "$fast_border" "$RESET" "$fast_mark" "Fast scan" "$fast_border" "$RESET" \ "$full_border" "$RESET" "$full_mark" "Full scan" "$full_border" "$RESET" echo -e "${fast_border}├──────────────────────────┤${RESET} ${full_border}├──────────────────────────┤${RESET}" printf "%b│%b %-22s %b│%b %b│%b %-22s %b│%b\n" \ "$fast_border" "$RESET" "Common cleanup spots" "$fast_border" "$RESET" \ "$full_border" "$RESET" "Deeper home scan" "$full_border" "$RESET" printf "%b│%b %-22s %b│%b %b│%b %-22s %b│%b\n" \ "$fast_border" "$RESET" "Quicker, recommended" "$fast_border" "$RESET" \ "$full_border" "$RESET" "Slower, more complete" "$full_border" "$RESET" echo -e "${fast_border}╰──────────────────────────╯${RESET} ${full_border}╰──────────────────────────╯${RESET}" echo "" echo -e "${GREEN}Current selection:${RESET} ${current_title} - ${current_note}" echo "" } > /dev/tty } read_menu_key() { local key rest IFS= read -rsn1 key < /dev/tty || return 1 if [[ "$key" == $'\x1b' ]]; then IFS= read -rsn2 -t 1 rest < /dev/tty || true key+="$rest" fi printf "%s" "$key" } enter_menu_screen() { printf "\033[?1049h\033[?25l\033[H\033[2J" > /dev/tty } exit_menu_screen() { printf "\033[?25h\033[?1049l" > /dev/tty } draw_delete_menu() { local cursor="$1" local count="${#CANDIDATE_PATHS[@]}" local window=14 local start=0 local end local i local selected_count=0 local selected_kb=0 local continue_border="$CYAN" local continue_pointer=" " local continue_text for ((i = 0; i < count; i++)); do if [[ "${SELECTED[$i]}" == "true" ]]; then selected_count=$((selected_count + 1)) selected_kb=$((selected_kb + CANDIDATE_KBS[$i])) fi done if (( cursor == count )); then continue_border="$GREEN" continue_pointer=">" fi if (( count > window )); then start=$((cursor - window / 2)) (( start < 0 )) && start=0 (( start > count - window )) && start=$((count - window)) fi end=$((start + window - 1)) (( end >= count )) && end=$((count - 1)) { printf "\033[H\033[J" echo "" echo -e "${BOLD}Choose Items To Delete${RESET}" line echo -e "${DIM}Use ↑/↓ to move, Enter/Space to tick. Tick \"Continue\" when done.${RESET}" if [[ "$AI_MODE" == "true" ]]; then echo -e "${GREEN}* EzClean AI pre-selected the safe-to-reclaim items. Untick anything you want to keep, then Continue.${RESET}" fi echo "" if (( start > 0 )); then echo -e "${DIM} ...${RESET}" fi for ((i = start; i <= end; i++)); do local pointer=" " local checked=" " (( i == cursor )) && pointer=">" [[ "${SELECTED[$i]}" == "true" ]] && checked="x" printf "%s [%s] %-7s %-22s %10s %s\n" \ "$pointer" \ "$checked" \ "${CANDIDATE_RISKS[$i]}" \ "${CANDIDATE_NAMES[$i]}" \ "$(human_size "${CANDIDATE_KBS[$i]}")" \ "${CANDIDATE_PATHS[$i]}" done if (( end < count - 1 )); then echo -e "${DIM} ...${RESET}" fi echo "" continue_text="${continue_pointer} Continue to delete preview Selected: ${selected_count} | $(human_size "$selected_kb")" echo -e "${continue_border}╭────────────────────────────────────────────────────────────╮${RESET}" printf "%b│%b %-56s %b│%b\n" "$continue_border" "$RESET" "$continue_text" "$continue_border" "$RESET" echo -e "${continue_border}╰────────────────────────────────────────────────────────────╯${RESET}" echo -e "${DIM}Move to the bottom and press Enter when ready.${RESET}" echo "" } > /dev/tty } choose_delete_items() { local count="${#CANDIDATE_PATHS[@]}" local cursor=0 local key local result=1 SELECTED=() if (( count == 0 )); then warn "No cleanup candidates found." return 1 fi if [[ ! -r /dev/tty ]]; then warn "No interactive terminal found. Showing scan only." return 1 fi local ai_selected=0 for ((i = 0; i < count; i++)); do if [[ "$AI_MODE" == "true" ]] && ai_should_select "${CANDIDATE_RISKS[$i]}" "${CANDIDATE_NAMES[$i]}"; then SELECTED+=("true") ai_selected=$((ai_selected + 1)) else SELECTED+=("false") fi done # With --ai, the safe wins are already ticked: start the cursor on "Continue" # so a single Enter confirms the AI plan. The user can still scroll up to edit. if [[ "$AI_MODE" == "true" ]] && (( ai_selected > 0 )); then cursor=$count fi enter_menu_screen while true; do draw_delete_menu "$cursor" key="$(read_menu_key)" || break case "$key" in $'\x1b[A'|k) (( cursor > 0 )) && cursor=$((cursor - 1)) ;; $'\x1b[B'|j) (( cursor < count )) && cursor=$((cursor + 1)) ;; ""|" "|$'\n'|$'\r') if (( cursor == count )); then result=0 break fi if [[ "${SELECTED[$cursor]}" == "true" ]]; then SELECTED[$cursor]="false" else SELECTED[$cursor]="true" fi ;; q|Q) break ;; esac done exit_menu_screen return "$result" } # Last line of defense before rm -rf. Returns 0 only if $path resolves to a real # location strictly inside the user's physical home. Canonicalizes via cd+pwd -P # (portable to macOS bash 3.2 — no GNU realpath needed) so a symlinked candidate # or a symlinked ancestor (e.g. ~/Library relocated to an external volume) cannot # trick rm into escaping $HOME. Defense in depth on top of add_candidate's checks. is_safe_to_delete() { local path="$1" # Obvious refusals. [ -n "$path" ] || return 1 [ "$path" = "/" ] && return 1 [ "$path" = "$HOME_DIR" ] && return 1 # Never delete *through* a symlink — rm on a symlinked dir/ancestor can reach # the real target outside the home tree. [ -L "$path" ] && return 1 [ -e "$path" ] || return 1 # Physical home (resolves any symlinks in the HOME path itself). local home_real home_real="$(cd "$HOME_DIR" 2>/dev/null && pwd -P)" || return 1 [ -n "$home_real" ] || return 1 # Resolve the candidate's parent to its physical path; this collapses any # symlinked ancestor so the containment check sees the true location. local parent base parent_real real parent="$(dirname -- "$path")" base="$(basename -- "$path")" parent_real="$(cd "$parent" 2>/dev/null && pwd -P)" || return 1 real="$parent_real/$base" # Must be strictly inside the real home, and not the home dir itself. case "$real" in "$home_real"/*) ;; *) return 1 ;; esac [ "$real" = "$home_real" ] && return 1 # Protected top-level user locations (exact match only — children are fine, # e.g. ~/Library/Caches is allowed but ~/Library is not). case "$real" in "$home_real"/Desktop|"$home_real"/Documents|"$home_real"/Downloads|\ "$home_real"/Movies|"$home_real"/Music|"$home_real"/Pictures|\ "$home_real"/Library|"$home_real"/.ssh|"$home_real"/.gnupg|"$home_real"/.config) return 1 ;; esac return 0 } # Percent-encode a path for a freedesktop .trashinfo Path= line (ASCII-safe; # leaves unreserved + '/' alone). Multibyte chars are left as-is, which the # common file managers tolerate. trash_urlencode() { local s="$1" out="" c i for (( i = 0; i < ${#s}; i++ )); do c="${s:i:1}" case "$c" in [a-zA-Z0-9/._~-]) out+="$c" ;; *) printf -v c '%%%02X' "'$c" 2>/dev/null && out+="$c" || out+="${s:i:1}" ;; esac done printf '%s' "$out" } # Move a path into the Trash, giving it a unique name on collision so we never # clobber an existing trashed item. On Linux, also writes the freedesktop # .trashinfo sidecar so GUI file managers can restore it. Reversible. # Returns 0 on success. Assumes is_safe_to_delete() already approved the path. trash_item() { local path="$1" local trash="$TRASH_DIR" # You can't move the Trash into the Trash. If the selected path IS the trash # (macOS ~/.Trash) or contains it (Linux ~/.local/share/Trash ⊃ .../files), # emptying its contents is the cleanup — inherently permanent, since those # items were already discarded. case "$trash/" in "$path/"|"$path"/*) rm -rf -- "$path"/* 2>/dev/null rm -rf -- "$path"/.[!.]* 2>/dev/null return 0 ;; esac mkdir -p "$trash" 2>/dev/null || return 1 local base dest base="$(basename -- "$path")" dest="$trash/$base" if [[ -e "$dest" || -L "$dest" ]]; then # Collide-proof suffix. No Date.now()/random needed — just probe upward. local n=1 while [[ -e "$trash/$base ($n)" || -L "$trash/$base ($n)" ]]; do n=$((n + 1)) done dest="$trash/$base ($n)" fi mv -f -- "$path" "$dest" 2>/dev/null || return 1 # Linux freedesktop trash spec: write info/.trashinfo so the item shows # up as restorable in file managers. (macOS Finder doesn't use this format.) if [ "${OS:-}" = "linux" ]; then local info_dir dest_base info_dir="${trash%/files}/info" dest_base="$(basename -- "$dest")" if mkdir -p "$info_dir" 2>/dev/null; then { printf '[Trash Info]\n' printf 'Path=%s\n' "$(trash_urlencode "$path")" printf 'DeletionDate=%s\n' "$(date +%Y-%m-%dT%H:%M:%S 2>/dev/null)" } > "$info_dir/$dest_base.trashinfo" 2>/dev/null fi fi return 0 } # Execute the chosen disposition for one already-approved path, honoring # DRY_RUN and HARD_DELETE. Prints a per-item status line. dispose_item() { local path="$1" if [[ "$DRY_RUN" == "true" ]]; then if [[ "$HARD_DELETE" == "true" ]]; then printf "would remove %s\n" "$path" else printf "would trash %s\n" "$path" fi return 0 fi if [[ "$HARD_DELETE" == "true" ]]; then printf "Removing %s ... " "$path" if rm -rf -- "$path"; then echo -e "${GREEN}done${RESET}" else echo -e "${RED}failed${RESET}" fi else printf "To Trash %s ... " "$path" if trash_item "$path"; then echo -e "${GREEN}done${RESET}" else echo -e "${RED}failed${RESET}" fi fi } preview_and_delete() { local count="${#CANDIDATE_PATHS[@]}" local selected_count=0 local total_kb=0 local i clear 2>/dev/null || true section "DELETE PREVIEW" for ((i = 0; i < count; i++)); do [[ "${SELECTED[$i]}" == "true" ]] || continue selected_count=$((selected_count + 1)) total_kb=$((total_kb + CANDIDATE_KBS[$i])) printf "[%d] %-22s %10s %s\n" \ "$selected_count" \ "${CANDIDATE_NAMES[$i]}" \ "$(human_size "${CANDIDATE_KBS[$i]}")" \ "${CANDIDATE_PATHS[$i]}" done if (( selected_count == 0 )); then echo "No items selected." echo "No files were deleted." return 0 fi echo "" echo -e "${BOLD}Total selected:${RESET} $(human_size "$total_kb")" if [[ "$HARD_DELETE" == "true" ]]; then echo -e "${RED}Mode: permanent delete (--hard) — items will NOT be recoverable.${RESET}" else echo -e "${DIM}Mode: move to Trash (recoverable). Use --hard to delete permanently.${RESET}" fi echo "" # Dry-run: print the plan and stop before any filesystem change. if [[ "$DRY_RUN" == "true" ]]; then section "DRY RUN — no files will be touched" for ((i = 0; i < count; i++)); do [[ "${SELECTED[$i]}" == "true" ]] || continue local dpath="${CANDIDATE_PATHS[$i]}" if is_safe_to_delete "$dpath"; then dispose_item "$dpath" else printf "would SKIP %s (unsafe: outside home, symlinked, or protected)\n" "$dpath" fi done echo "" done_msg "Dry run complete. Nothing was changed." return 0 fi local verb="move these to Trash" [[ "$HARD_DELETE" == "true" ]] && verb="PERMANENTLY delete these" if ! ask_yes_no "$verb $selected_count item(s)?"; then echo "Cancelled. No files were deleted." return 0 fi echo "" for ((i = 0; i < count; i++)); do [[ "${SELECTED[$i]}" == "true" ]] || continue local path="${CANDIDATE_PATHS[$i]}" if ! is_safe_to_delete "$path"; then warn "Skipped unsafe path (outside home, symlinked, or protected): $path" continue fi dispose_item "$path" done echo "" if [[ "$HARD_DELETE" == "true" ]]; then done_msg "Delete step complete" else done_msg "Moved to Trash. Empty Trash to reclaim the space." fi } run_scan() { header echo -e "${DIM}Tip: --fast scans common folders. --deep scans much more and can be slow.${RESET}" echo -e "${DIM}No files are deleted during scan.${RESET}" echo "" scan_core scan_review scan_danger scan_installers_archives scan_large_documents scan_creative_files scan_node_modules scan_vm_images scan_unknown_big_library_folders if [[ "$MODE" == "deep" ]]; then scan_top_home_folders else section "TOP HOME FOLDERS" warn "Skipped in fast mode. Run with --deep to include this." fi scan_apps echo "" line echo -e "${GREEN}Scan complete.${RESET}" echo "No files were deleted during scan." echo "" } # If the user interrupts during the full-screen menu, leave the terminal in a # sane state (cursor visible, primary screen) instead of a frozen alt-screen. restore_terminal() { printf "\033[?25h\033[?1049l" > /dev/tty 2>/dev/null || true } on_interrupt() { restore_terminal echo "" echo "Interrupted. No further changes were made." exit 130 } trap on_interrupt INT TERM # EzClean AI rule (shared by the interactive menu and --dry-run). Decides whether # a scanned candidate should be pre-selected. All `safe` items regenerate on # their own. Among `review` items, only clearly-regenerable build/dependency # caches and old installers qualify — never personal data (device backups, # media, documents, downloads, chat/app data). Pure local string rule; no model. ai_should_select() { local risk="$1" name="$2" [[ "$risk" == "safe" ]] && return 0 if [[ "$risk" == "review" ]]; then case "$name" in "Docker data"|"Docker app data"|"containerd data"|\ "Xcode Archives"|"iOS DeviceSupport"|"iOS Simulators"|\ "Android SDK"|"Android Studio"|"Android AVD"|\ "Maven repo"|"NuGet packages"|".NET folder"|\ "Old archive") return 0 ;; esac fi return 1 } # Pre-select default items without any TTY interaction. Used by --dry-run. # Default policy: 'safe' only. With --ai: 'safe' + regenerable 'review' (per # ai_should_select), matching the interactive checklist's starting selection. auto_select_safe() { local count="${#CANDIDATE_PATHS[@]}" local i SELECTED=() for ((i = 0; i < count; i++)); do if [[ "$AI_MODE" == "true" ]]; then if ai_should_select "${CANDIDATE_RISKS[$i]}" "${CANDIDATE_NAMES[$i]}"; then SELECTED+=("true") else SELECTED+=("false") fi elif [[ "${CANDIDATE_RISKS[$i]}" == "safe" ]]; then SELECTED+=("true") else SELECTED+=("false") fi done } # Non-interactive dry run: scan, auto-select safe items, print the exact plan. run_dry_run() { run_scan if (( ${#CANDIDATE_PATHS[@]} == 0 )); then warn "No cleanup candidates found." return 0 fi auto_select_safe preview_and_delete } run_interactive() { if [[ "$MODE_WAS_SET" == "true" ]]; then header fi if ! choose_scan_mode; then echo "Cancelled. No files were deleted." return 0 fi run_scan if ! choose_delete_items; then echo "Cancelled. No files were deleted." return 0 fi preview_and_delete } echo -e "${DIM}EzClean ${VERSION} (${BUILD_REF})${RESET}" case "${ACTION}" in scan) run_scan ;; *) if [[ "$DRY_RUN" == "true" ]]; then run_dry_run else run_interactive fi ;; esac EZCLEAN_BASH_SCRIPT