From 5955bd93dc4e2e30a723ccbf7f4014331c068acc Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 3 Jan 2026 13:29:07 +0800 Subject: [PATCH] feat: enhance clean logic 1. Add recursive empty directory cleanup for Application Support and Caches. 2. Add support for cleaning old Edge Updater versions. --- lib/clean/user.sh | 110 +++++++++++++++++++++++++++- tests/clean_browser_versions.bats | 33 +++++++++ tests/clean_core.bats | 117 +++++++++++++++++++++++------- 3 files changed, 230 insertions(+), 30 deletions(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index f1e6a01..9cc616f 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -22,6 +22,7 @@ clean_empty_library_items() { return 0 fi + # 1. Clean top-level empty directories in Library local -a empty_dirs=() while IFS= read -r -d '' dir; do [[ -d "$dir" ]] && empty_dirs+=("$dir") @@ -31,6 +32,48 @@ clean_empty_library_items() { safe_clean "${empty_dirs[@]}" "Empty Library folders" fi + # 2. Clean empty subdirectories in Application Support and other key locations + # Iteratively remove empty directories until no more are found + local -a key_locations=( + "$HOME/Library/Application Support" + "$HOME/Library/Caches" + ) + + for location in "${key_locations[@]}"; do + [[ -d "$location" ]] || continue + + # Limit passes to keep cleanup fast; one extra pass catches most parents. + local max_iterations=2 + local iteration=0 + + while [[ $iteration -lt $max_iterations ]]; do + local -a nested_empty_dirs=() + # Find empty directories + while IFS= read -r -d '' dir; do + # Skip if whitelisted + if is_path_whitelisted "$dir"; then + continue + fi + # Skip protected system components + local dir_name=$(basename "$dir") + if is_critical_system_component "$dir_name"; then + continue + fi + [[ -d "$dir" ]] && nested_empty_dirs+=("$dir") + done < <(find "$location" -mindepth 1 -type d -empty -print0 2> /dev/null) + + # If no empty dirs found, we're done with this location + if [[ ${#nested_empty_dirs[@]} -eq 0 ]]; then + break + fi + + local location_name=$(basename "$location") + safe_clean "${nested_empty_dirs[@]}" "Empty $location_name subdirs" + + ((iteration++)) + done + done + # Empty file cleanup is skipped to avoid removing app sentinel files. } @@ -194,6 +237,68 @@ clean_edge_old_versions() { fi } +# Remove old Microsoft EdgeUpdater versions while keeping latest. +clean_edge_updater_old_versions() { + local updater_dir="$HOME/Library/Application Support/Microsoft/EdgeUpdater/apps/msedge-stable" + [[ -d "$updater_dir" ]] || return 0 + + if pgrep -f "Microsoft Edge" > /dev/null 2>&1; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running ยท updater cleanup skipped" + return 0 + fi + + local -a version_dirs=() + local dir + for dir in "$updater_dir"/*; do + [[ -d "$dir" ]] || continue + version_dirs+=("$dir") + done + + if [[ ${#version_dirs[@]} -lt 2 ]]; then + return 0 + fi + + local latest_version + latest_version=$(printf '%s\n' "${version_dirs[@]##*/}" | sort -V | tail -n 1) + [[ -n "$latest_version" ]] || return 0 + + local cleaned_count=0 + local total_size=0 + local cleaned_any=false + + for dir in "${version_dirs[@]}"; do + local name + name=$(basename "$dir") + [[ "$name" == "$latest_version" ]] && continue + if is_path_whitelisted "$dir"; then + continue + fi + local size_kb + size_kb=$(get_path_size_kb "$dir" || echo 0) + size_kb="${size_kb:-0}" + total_size=$((total_size + size_kb)) + ((cleaned_count++)) + cleaned_any=true + if [[ "$DRY_RUN" != "true" ]]; then + safe_remove "$dir" true > /dev/null 2>&1 || true + fi + done + + if [[ "$cleaned_any" == "true" ]]; then + local size_human + size_human=$(bytes_to_human "$((total_size * 1024))") + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge updater old versions ${YELLOW}(${cleaned_count} dirs, $size_human dry)${NC}" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge updater old versions ${GREEN}(${cleaned_count} dirs, $size_human)${NC}" + fi + ((files_cleaned += cleaned_count)) + ((total_size_cleaned += total_size)) + ((total_items++)) + note_activity + fi +} + scan_external_volumes() { [[ -d "/Volumes" ]] || return 0 local -a candidate_volumes=() @@ -296,7 +401,7 @@ clean_recent_items() { } clean_mail_downloads() { stop_section_spinner - local mail_age_days=${MOLE_MAIL_AGE_DAYS:-30} + local mail_age_days=$MOLE_MAIL_AGE_DAYS if ! [[ "$mail_age_days" =~ ^[0-9]+$ ]]; then mail_age_days=30 fi @@ -313,7 +418,7 @@ clean_mail_downloads() { if ! [[ "$dir_size_kb" =~ ^[0-9]+$ ]]; then dir_size_kb=0 fi - local min_kb="${MOLE_MAIL_DOWNLOADS_MIN_KB:-5120}" + local min_kb="$MOLE_MAIL_DOWNLOADS_MIN_KB" if ! [[ "$min_kb" =~ ^[0-9]+$ ]]; then min_kb=5120 fi @@ -426,6 +531,7 @@ clean_browsers() { safe_clean ~/Library/Application\ Support/Firefox/Profiles/*/cache2/* "Firefox profile cache" clean_chrome_old_versions clean_edge_old_versions + clean_edge_updater_old_versions } # Cloud storage caches. clean_cloud_storage() { diff --git a/tests/clean_browser_versions.bats b/tests/clean_browser_versions.bats index 1e06b0a..78d44a3 100644 --- a/tests/clean_browser_versions.bats +++ b/tests/clean_browser_versions.bats @@ -103,6 +103,39 @@ is_path_whitelisted() { [[ "$1" == *"128.0.0.0"* ]] && return 0 return 1 } + +@test "clean_edge_updater_old_versions keeps latest version" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +pgrep() { return 1; } +export -f pgrep + +UPDATER_DIR="$HOME/Library/Application Support/Microsoft/EdgeUpdater/apps/msedge-stable" +mkdir -p "$UPDATER_DIR"/{117.0.2045.60,118.0.2088.46,119.0.2108.9} + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "10240"; } +bytes_to_human() { echo "10M"; } +note_activity() { :; } +export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_edge_updater_old_versions + +echo "Cleaned: $files_cleaned items" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Edge updater old versions"* ]] + [[ "$output" == *"dry"* ]] + [[ "$output" == *"Cleaned: 2 items"* ]] +} get_path_size_kb() { echo "10240"; } bytes_to_human() { echo "10M"; } note_activity() { :; } diff --git a/tests/clean_core.bats b/tests/clean_core.bats index c039cfa..16dba03 100644 --- a/tests/clean_core.bats +++ b/tests/clean_core.bats @@ -69,34 +69,6 @@ EOF [[ "$output" != *"Maven repository cache"* ]] } -@test "mo clean respects MO_BREW_TIMEOUT environment variable" { - if ! command -v brew > /dev/null 2>&1; then - skip "Homebrew not installed" - fi - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/brew.sh" - -MO_BREW_TIMEOUT=5 -CALL_LOG="$HOME/timeout.log" - -run_with_timeout() { - echo "$1" >> "$CALL_LOG" - shift - "$@" -} - -brew() { return 0; } - -clean_homebrew -cat "$CALL_LOG" -EOF - [ "$status" -eq 0 ] - [[ "$output" == *"5"* ]] -} - @test "FINDER_METADATA_SENTINEL in whitelist protects .DS_Store files" { mkdir -p "$HOME/Documents" touch "$HOME/Documents/.DS_Store" @@ -277,3 +249,92 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Time Machine backup in progress, skipping cleanup"* ]] } + +@test "clean_empty_library_items removes nested empty directories in Application Support" { + # Create nested empty directory structure + mkdir -p "$HOME/Library/Application Support/UninstalledApp1/SubDir/DeepDir" + mkdir -p "$HOME/Library/Application Support/UninstalledApp2/Cache" + mkdir -p "$HOME/Library/Application Support/ActiveApp/Data" + mkdir -p "$HOME/Library/Caches/EmptyCache/SubCache" + + # Create a file in ActiveApp to make it non-empty + touch "$HOME/Library/Application Support/ActiveApp/Data/config.json" + + # Create top-level empty directory in Library + mkdir -p "$HOME/Library/EmptyTopLevel" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +# Mock dependencies +is_path_whitelisted() { return 1; } +is_critical_system_component() { return 1; } +bytes_to_human() { echo "$1"; } +note_activity() { :; } +safe_clean() { + # Actually remove the directories for testing + for path in "$@"; do + if [ "$path" != "${@: -1}" ]; then # Skip the description (last arg) + rm -rf "$path" 2>/dev/null || true + fi + done +} + +clean_empty_library_items +EOF + + [ "$status" -eq 0 ] + + # Empty nested dirs should be removed + [ ! -d "$HOME/Library/Application Support/UninstalledApp1" ] + [ ! -d "$HOME/Library/Application Support/UninstalledApp2" ] + [ ! -d "$HOME/Library/Caches/EmptyCache" ] + [ ! -d "$HOME/Library/EmptyTopLevel" ] + + # Non-empty directory should remain + [ -d "$HOME/Library/Application Support/ActiveApp" ] + [ -f "$HOME/Library/Application Support/ActiveApp/Data/config.json" ] +} + +@test "clean_empty_library_items respects whitelist for empty directories" { + mkdir -p "$HOME/Library/Application Support/ProtectedEmptyApp" + mkdir -p "$HOME/Library/Application Support/UnprotectedEmptyApp" + mkdir -p "$HOME/.config/mole" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +# Mock dependencies +is_critical_system_component() { return 1; } +bytes_to_human() { echo "$1"; } +note_activity() { :; } + +# Mock whitelist to protect ProtectedEmptyApp +is_path_whitelisted() { + [[ "$1" == *"ProtectedEmptyApp"* ]] +} + +safe_clean() { + # Actually remove the directories for testing + for path in "$@"; do + if [ "$path" != "${@: -1}" ]; then # Skip the description (last arg) + rm -rf "$path" 2>/dev/null || true + fi + done +} + +clean_empty_library_items +EOF + + [ "$status" -eq 0 ] + + # Whitelisted directory should remain even if empty + [ -d "$HOME/Library/Application Support/ProtectedEmptyApp" ] + + # Non-whitelisted directory should be removed + [ ! -d "$HOME/Library/Application Support/UnprotectedEmptyApp" ] +}