mirror of
https://github.com/tw93/Mole.git
synced 2026-03-22 22:30:08 +00:00
The dry-run mode hardcoded SYSTEM_CLEAN=false, which meant the entire system section was silently skipped during preview. Users had no way to see what /Library/Caches, /private/var/log, diagnostics, or other sudo-required paths would be cleaned. Now dry-run checks for a cached sudo session (sudo -n) and includes the system preview when available. When sudo isn't cached, a hint tells the user how to get the full preview without running the whole script as root.
1121 lines
36 KiB
Bash
Executable File
1121 lines
36 KiB
Bash
Executable File
#!/bin/bash
|
|
# Mole - Clean command.
|
|
# Runs cleanup modules with optional sudo.
|
|
# Supports dry-run and whitelist.
|
|
|
|
set -euo pipefail
|
|
|
|
export LC_ALL=C
|
|
export LANG=C
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
source "$SCRIPT_DIR/../lib/core/common.sh"
|
|
|
|
source "$SCRIPT_DIR/../lib/core/sudo.sh"
|
|
source "$SCRIPT_DIR/../lib/clean/brew.sh"
|
|
source "$SCRIPT_DIR/../lib/clean/caches.sh"
|
|
source "$SCRIPT_DIR/../lib/clean/apps.sh"
|
|
source "$SCRIPT_DIR/../lib/clean/dev.sh"
|
|
source "$SCRIPT_DIR/../lib/clean/app_caches.sh"
|
|
source "$SCRIPT_DIR/../lib/clean/hints.sh"
|
|
source "$SCRIPT_DIR/../lib/clean/system.sh"
|
|
source "$SCRIPT_DIR/../lib/clean/user.sh"
|
|
|
|
SYSTEM_CLEAN=false
|
|
DRY_RUN=false
|
|
PROTECT_FINDER_METADATA=false
|
|
IS_M_SERIES=$([[ "$(uname -m)" == "arm64" ]] && echo "true" || echo "false")
|
|
|
|
EXPORT_LIST_FILE="$HOME/.config/mole/clean-list.txt"
|
|
CURRENT_SECTION=""
|
|
readonly PROTECTED_SW_DOMAINS=(
|
|
"capcut.com"
|
|
"photopea.com"
|
|
"pixlr.com"
|
|
)
|
|
|
|
declare -a WHITELIST_PATTERNS=()
|
|
WHITELIST_WARNINGS=()
|
|
if [[ -f "$HOME/.config/mole/whitelist" ]]; then
|
|
while IFS= read -r line; do
|
|
# shellcheck disable=SC2295
|
|
line="${line#"${line%%[![:space:]]*}"}"
|
|
# shellcheck disable=SC2295
|
|
line="${line%"${line##*[![:space:]]}"}"
|
|
[[ -z "$line" || "$line" =~ ^# ]] && continue
|
|
|
|
[[ "$line" == ~* ]] && line="${line/#~/$HOME}"
|
|
line="${line//\$HOME/$HOME}"
|
|
line="${line//\$\{HOME\}/$HOME}"
|
|
if [[ "$line" =~ \.\. ]]; then
|
|
WHITELIST_WARNINGS+=("Path traversal not allowed: $line")
|
|
continue
|
|
fi
|
|
|
|
if [[ "$line" != "$FINDER_METADATA_SENTINEL" ]]; then
|
|
if [[ ! "$line" =~ ^[a-zA-Z0-9/_.@\ *-]+$ ]]; then
|
|
WHITELIST_WARNINGS+=("Invalid path format: $line")
|
|
continue
|
|
fi
|
|
|
|
if [[ "$line" != /* ]]; then
|
|
WHITELIST_WARNINGS+=("Must be absolute path: $line")
|
|
continue
|
|
fi
|
|
fi
|
|
|
|
if [[ "$line" =~ // ]]; then
|
|
WHITELIST_WARNINGS+=("Consecutive slashes: $line")
|
|
continue
|
|
fi
|
|
|
|
case "$line" in
|
|
/ | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /etc | /etc/* | /var/db | /var/db/*)
|
|
WHITELIST_WARNINGS+=("Protected system path: $line")
|
|
continue
|
|
;;
|
|
esac
|
|
|
|
duplicate="false"
|
|
if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then
|
|
for existing in "${WHITELIST_PATTERNS[@]}"; do
|
|
if [[ "$line" == "$existing" ]]; then
|
|
duplicate="true"
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
[[ "$duplicate" == "true" ]] && continue
|
|
WHITELIST_PATTERNS+=("$line")
|
|
done < "$HOME/.config/mole/whitelist"
|
|
else
|
|
WHITELIST_PATTERNS=("${DEFAULT_WHITELIST_PATTERNS[@]}")
|
|
fi
|
|
|
|
# Expand whitelist patterns once to avoid repeated tilde expansion in hot loops.
|
|
expand_whitelist_patterns() {
|
|
if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then
|
|
local -a EXPANDED_PATTERNS
|
|
EXPANDED_PATTERNS=()
|
|
for pattern in "${WHITELIST_PATTERNS[@]}"; do
|
|
local expanded="${pattern/#\~/$HOME}"
|
|
EXPANDED_PATTERNS+=("$expanded")
|
|
done
|
|
WHITELIST_PATTERNS=("${EXPANDED_PATTERNS[@]}")
|
|
fi
|
|
}
|
|
expand_whitelist_patterns
|
|
|
|
if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then
|
|
for entry in "${WHITELIST_PATTERNS[@]}"; do
|
|
if [[ "$entry" == "$FINDER_METADATA_SENTINEL" ]]; then
|
|
PROTECT_FINDER_METADATA=true
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# Section tracking and summary counters.
|
|
total_items=0
|
|
TRACK_SECTION=0
|
|
SECTION_ACTIVITY=0
|
|
files_cleaned=0
|
|
total_size_cleaned=0
|
|
whitelist_skipped_count=0
|
|
PROJECT_ARTIFACT_HINT_DETECTED=false
|
|
PROJECT_ARTIFACT_HINT_COUNT=0
|
|
PROJECT_ARTIFACT_HINT_TRUNCATED=false
|
|
PROJECT_ARTIFACT_HINT_EXAMPLES=()
|
|
PROJECT_ARTIFACT_HINT_ESTIMATED_KB=0
|
|
PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES=0
|
|
PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=false
|
|
|
|
# shellcheck disable=SC2329
|
|
note_activity() {
|
|
if [[ "${TRACK_SECTION:-0}" == "1" ]]; then
|
|
SECTION_ACTIVITY=1
|
|
fi
|
|
}
|
|
|
|
CLEANUP_DONE=false
|
|
# shellcheck disable=SC2329
|
|
cleanup() {
|
|
local signal="${1:-EXIT}"
|
|
local exit_code="${2:-$?}"
|
|
|
|
if [[ "$CLEANUP_DONE" == "true" ]]; then
|
|
return 0
|
|
fi
|
|
CLEANUP_DONE=true
|
|
|
|
stop_inline_spinner 2> /dev/null || true
|
|
|
|
cleanup_temp_files
|
|
|
|
stop_sudo_session
|
|
|
|
show_cursor
|
|
}
|
|
|
|
trap 'cleanup EXIT $?' EXIT
|
|
trap 'cleanup INT 130; exit 130' INT
|
|
trap 'cleanup TERM 143; exit 143' TERM
|
|
|
|
start_section() {
|
|
TRACK_SECTION=1
|
|
SECTION_ACTIVITY=0
|
|
CURRENT_SECTION="$1"
|
|
echo ""
|
|
echo -e "${PURPLE_BOLD}${ICON_ARROW} $1${NC}"
|
|
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
ensure_user_file "$EXPORT_LIST_FILE"
|
|
echo "" >> "$EXPORT_LIST_FILE"
|
|
echo "=== $1 ===" >> "$EXPORT_LIST_FILE"
|
|
fi
|
|
}
|
|
|
|
end_section() {
|
|
stop_section_spinner
|
|
|
|
if [[ "${TRACK_SECTION:-0}" == "1" && "${SECTION_ACTIVITY:-0}" == "0" ]]; then
|
|
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Nothing to clean"
|
|
fi
|
|
TRACK_SECTION=0
|
|
}
|
|
|
|
# shellcheck disable=SC2329
|
|
normalize_paths_for_cleanup() {
|
|
local -a input_paths=("$@")
|
|
local -a unique_paths=()
|
|
|
|
for path in "${input_paths[@]}"; do
|
|
local normalized="${path%/}"
|
|
[[ -z "$normalized" ]] && normalized="$path"
|
|
local found=false
|
|
if [[ ${#unique_paths[@]} -gt 0 ]]; then
|
|
for existing in "${unique_paths[@]}"; do
|
|
if [[ "$existing" == "$normalized" ]]; then
|
|
found=true
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
[[ "$found" == "true" ]] || unique_paths+=("$normalized")
|
|
done
|
|
|
|
local sorted_paths
|
|
if [[ ${#unique_paths[@]} -gt 0 ]]; then
|
|
sorted_paths=$(printf '%s\n' "${unique_paths[@]}" | awk '{print length "|" $0}' | LC_ALL=C sort -n | cut -d'|' -f2-)
|
|
else
|
|
sorted_paths=""
|
|
fi
|
|
|
|
local -a result_paths=()
|
|
while IFS= read -r path; do
|
|
[[ -z "$path" ]] && continue
|
|
local is_child=false
|
|
if [[ ${#result_paths[@]} -gt 0 ]]; then
|
|
for kept in "${result_paths[@]}"; do
|
|
if [[ "$path" == "$kept" || "$path" == "$kept"/* ]]; then
|
|
is_child=true
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
[[ "$is_child" == "true" ]] || result_paths+=("$path")
|
|
done <<< "$sorted_paths"
|
|
|
|
if [[ ${#result_paths[@]} -gt 0 ]]; then
|
|
printf '%s\n' "${result_paths[@]}"
|
|
fi
|
|
}
|
|
|
|
# shellcheck disable=SC2329
|
|
get_cleanup_path_size_kb() {
|
|
local path="$1"
|
|
|
|
if [[ -f "$path" && ! -L "$path" ]]; then
|
|
if command -v stat > /dev/null 2>&1; then
|
|
local bytes
|
|
bytes=$(stat -f%z "$path" 2> /dev/null || echo "0")
|
|
if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then
|
|
echo $(((bytes + 1023) / 1024))
|
|
return 0
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [[ -L "$path" ]]; then
|
|
if command -v stat > /dev/null 2>&1; then
|
|
local bytes
|
|
bytes=$(stat -f%z "$path" 2> /dev/null || echo "0")
|
|
if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then
|
|
echo $(((bytes + 1023) / 1024))
|
|
else
|
|
echo 0
|
|
fi
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
get_path_size_kb "$path"
|
|
}
|
|
|
|
# Classification helper for cleanup risk levels
|
|
# shellcheck disable=SC2329
|
|
classify_cleanup_risk() {
|
|
local description="$1"
|
|
local path="${2:-}"
|
|
|
|
# HIGH RISK: System files, preference files, require sudo
|
|
if [[ "$description" =~ [Ss]ystem || "$description" =~ [Ss]udo || "$path" =~ ^/System || "$path" =~ ^/Library ]]; then
|
|
echo "HIGH|System files or requires admin access"
|
|
return
|
|
fi
|
|
|
|
# HIGH RISK: Preference files that might affect app functionality
|
|
if [[ "$description" =~ [Pp]reference || "$path" =~ /Preferences/ ]]; then
|
|
echo "HIGH|Preference files may affect app settings"
|
|
return
|
|
fi
|
|
|
|
# MEDIUM RISK: Installers, large files, app bundles
|
|
if [[ "$description" =~ [Ii]nstaller || "$description" =~ [Aa]pp.*[Bb]undle || "$description" =~ [Ll]arge ]]; then
|
|
echo "MEDIUM|Installer packages or app data"
|
|
return
|
|
fi
|
|
|
|
# MEDIUM RISK: Old backups, downloads
|
|
if [[ "$description" =~ [Bb]ackup || "$description" =~ [Dd]ownload || "$description" =~ [Oo]rphan ]]; then
|
|
echo "MEDIUM|Backup or downloaded files"
|
|
return
|
|
fi
|
|
|
|
# LOW RISK: Caches, logs, temporary files (automatically regenerated)
|
|
if [[ "$description" =~ [Cc]ache || "$description" =~ [Ll]og || "$description" =~ [Tt]emp || "$description" =~ [Tt]humbnail ]]; then
|
|
echo "LOW|Cache/log files, automatically regenerated"
|
|
return
|
|
fi
|
|
|
|
# DEFAULT: MEDIUM
|
|
echo "MEDIUM|User data files"
|
|
}
|
|
|
|
# shellcheck disable=SC2329
|
|
safe_clean() {
|
|
if [[ $# -eq 0 ]]; then
|
|
return 0
|
|
fi
|
|
|
|
local description
|
|
local -a targets
|
|
|
|
if [[ $# -eq 1 ]]; then
|
|
description="$1"
|
|
targets=("$1")
|
|
else
|
|
description="${*: -1}"
|
|
targets=("${@:1:$#-1}")
|
|
fi
|
|
|
|
local -a valid_targets=()
|
|
for target in "${targets[@]}"; do
|
|
# Optimization: If target is a glob literal and parent dir missing, skip it.
|
|
if [[ "$target" == *"*"* && ! -e "$target" ]]; then
|
|
local base_path="${target%%\**}"
|
|
local parent_dir
|
|
if [[ "$base_path" == */ ]]; then
|
|
parent_dir="${base_path%/}"
|
|
else
|
|
parent_dir=$(dirname "$base_path")
|
|
fi
|
|
|
|
if [[ ! -d "$parent_dir" ]]; then
|
|
# debug_log "Skipping nonexistent parent: $parent_dir for $target"
|
|
continue
|
|
fi
|
|
fi
|
|
valid_targets+=("$target")
|
|
done
|
|
|
|
if [[ ${#valid_targets[@]} -gt 0 ]]; then
|
|
targets=("${valid_targets[@]}")
|
|
else
|
|
targets=()
|
|
fi
|
|
if [[ ${#targets[@]} -eq 0 ]]; then
|
|
return 0
|
|
fi
|
|
|
|
local removed_any=0
|
|
local total_size_kb=0
|
|
local total_count=0
|
|
local skipped_count=0
|
|
local removal_failed_count=0
|
|
local permission_start=${MOLE_PERMISSION_DENIED_COUNT:-0}
|
|
|
|
local show_scan_feedback=false
|
|
if [[ ${#targets[@]} -gt 20 && -t 1 ]]; then
|
|
show_scan_feedback=true
|
|
stop_section_spinner
|
|
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning ${#targets[@]} items..."
|
|
fi
|
|
|
|
local -a existing_paths=()
|
|
for path in "${targets[@]}"; do
|
|
local skip=false
|
|
|
|
if should_protect_path "$path"; then
|
|
skip=true
|
|
((skipped_count++))
|
|
log_operation "clean" "SKIPPED" "$path" "protected"
|
|
fi
|
|
|
|
[[ "$skip" == "true" ]] && continue
|
|
|
|
if is_path_whitelisted "$path"; then
|
|
skip=true
|
|
((skipped_count++))
|
|
log_operation "clean" "SKIPPED" "$path" "whitelist"
|
|
fi
|
|
[[ "$skip" == "true" ]] && continue
|
|
[[ -e "$path" ]] && existing_paths+=("$path")
|
|
done
|
|
|
|
if [[ "$show_scan_feedback" == "true" ]]; then
|
|
stop_section_spinner
|
|
fi
|
|
|
|
debug_log "Cleaning: $description, ${#existing_paths[@]} items"
|
|
|
|
# Enhanced debug output with risk level and details
|
|
if [[ "${MO_DEBUG:-}" == "1" && ${#existing_paths[@]} -gt 0 ]]; then
|
|
# Determine risk level for this cleanup operation
|
|
local risk_info
|
|
risk_info=$(classify_cleanup_risk "$description" "${existing_paths[0]}")
|
|
local risk_level="${risk_info%%|*}"
|
|
local risk_reason="${risk_info#*|}"
|
|
|
|
debug_operation_start "$description"
|
|
debug_risk_level "$risk_level" "$risk_reason"
|
|
debug_operation_detail "Item count" "${#existing_paths[@]}"
|
|
|
|
# Log sample of files (first 10) with details
|
|
if [[ ${#existing_paths[@]} -le 10 ]]; then
|
|
debug_operation_detail "Files to be removed" "All files listed below"
|
|
else
|
|
debug_operation_detail "Files to be removed" "Showing first 10 of ${#existing_paths[@]} files"
|
|
fi
|
|
fi
|
|
|
|
if [[ $skipped_count -gt 0 ]]; then
|
|
((whitelist_skipped_count += skipped_count))
|
|
fi
|
|
|
|
if [[ ${#existing_paths[@]} -eq 0 ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if [[ ${#existing_paths[@]} -gt 1 ]]; then
|
|
local -a normalized_paths=()
|
|
while IFS= read -r path; do
|
|
[[ -n "$path" ]] && normalized_paths+=("$path")
|
|
done < <(normalize_paths_for_cleanup "${existing_paths[@]}")
|
|
|
|
if [[ ${#normalized_paths[@]} -gt 0 ]]; then
|
|
existing_paths=("${normalized_paths[@]}")
|
|
else
|
|
existing_paths=()
|
|
fi
|
|
fi
|
|
|
|
local show_spinner=false
|
|
if [[ ${#existing_paths[@]} -gt 10 ]]; then
|
|
show_spinner=true
|
|
local total_paths=${#existing_paths[@]}
|
|
if [[ -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning items..."; fi
|
|
fi
|
|
|
|
local cleaning_spinner_started=false
|
|
|
|
# For larger batches, precompute sizes in parallel for better UX/stat accuracy.
|
|
if [[ ${#existing_paths[@]} -gt 3 ]]; then
|
|
local temp_dir
|
|
temp_dir=$(create_temp_dir)
|
|
|
|
local dir_count=0
|
|
local sample_size=$((${#existing_paths[@]} > 20 ? 20 : ${#existing_paths[@]}))
|
|
local max_sample=$((${#existing_paths[@]} * 20 / 100))
|
|
[[ $max_sample -gt $sample_size ]] && sample_size=$max_sample
|
|
|
|
for ((i = 0; i < sample_size && i < ${#existing_paths[@]}; i++)); do
|
|
[[ -d "${existing_paths[i]}" ]] && ((dir_count++))
|
|
done
|
|
|
|
# Heuristic: mostly files -> sequential stat is faster than subshells.
|
|
if [[ $dir_count -lt 5 && ${#existing_paths[@]} -gt 20 ]]; then
|
|
if [[ -t 1 && "$show_spinner" == "false" ]]; then
|
|
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning items..."
|
|
show_spinner=true
|
|
fi
|
|
|
|
local idx=0
|
|
local last_progress_update
|
|
last_progress_update=$(get_epoch_seconds)
|
|
for path in "${existing_paths[@]}"; do
|
|
local size
|
|
size=$(get_cleanup_path_size_kb "$path")
|
|
[[ ! "$size" =~ ^[0-9]+$ ]] && size=0
|
|
|
|
if [[ "$size" -gt 0 ]]; then
|
|
echo "$size 1" > "$temp_dir/result_${idx}"
|
|
else
|
|
echo "0 0" > "$temp_dir/result_${idx}"
|
|
fi
|
|
|
|
((idx++))
|
|
if [[ $((idx % 20)) -eq 0 && "$show_spinner" == "true" && -t 1 ]]; then
|
|
update_progress_if_needed "$idx" "${#existing_paths[@]}" last_progress_update 1 || true
|
|
last_progress_update=$(get_epoch_seconds)
|
|
fi
|
|
done
|
|
else
|
|
local -a pids=()
|
|
local idx=0
|
|
local completed=0
|
|
local last_progress_update
|
|
last_progress_update=$(get_epoch_seconds)
|
|
local total_paths=${#existing_paths[@]}
|
|
|
|
if [[ ${#existing_paths[@]} -gt 0 ]]; then
|
|
for path in "${existing_paths[@]}"; do
|
|
(
|
|
local size
|
|
size=$(get_cleanup_path_size_kb "$path")
|
|
[[ ! "$size" =~ ^[0-9]+$ ]] && size=0
|
|
local tmp_file="$temp_dir/result_${idx}.$$"
|
|
if [[ "$size" -gt 0 ]]; then
|
|
echo "$size 1" > "$tmp_file"
|
|
else
|
|
echo "0 0" > "$tmp_file"
|
|
fi
|
|
mv "$tmp_file" "$temp_dir/result_${idx}" 2> /dev/null || true
|
|
) &
|
|
pids+=($!)
|
|
((idx++))
|
|
|
|
if ((${#pids[@]} >= MOLE_MAX_PARALLEL_JOBS)); then
|
|
wait "${pids[0]}" 2> /dev/null || true
|
|
pids=("${pids[@]:1}")
|
|
((completed++))
|
|
|
|
if [[ "$show_spinner" == "true" && -t 1 ]]; then
|
|
update_progress_if_needed "$completed" "$total_paths" last_progress_update 2 || true
|
|
fi
|
|
fi
|
|
done
|
|
fi
|
|
|
|
if [[ ${#pids[@]} -gt 0 ]]; then
|
|
for pid in "${pids[@]}"; do
|
|
wait "$pid" 2> /dev/null || true
|
|
((completed++))
|
|
|
|
if [[ "$show_spinner" == "true" && -t 1 ]]; then
|
|
update_progress_if_needed "$completed" "$total_paths" last_progress_update 2 || true
|
|
fi
|
|
done
|
|
fi
|
|
fi
|
|
|
|
# Read results back in original order.
|
|
# Start spinner for cleaning phase
|
|
if [[ "$DRY_RUN" != "true" && ${#existing_paths[@]} -gt 0 && -t 1 ]]; then
|
|
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Cleaning..."
|
|
cleaning_spinner_started=true
|
|
fi
|
|
idx=0
|
|
if [[ ${#existing_paths[@]} -gt 0 ]]; then
|
|
for path in "${existing_paths[@]}"; do
|
|
local result_file="$temp_dir/result_${idx}"
|
|
if [[ -f "$result_file" ]]; then
|
|
read -r size count < "$result_file" 2> /dev/null || true
|
|
local removed=0
|
|
if [[ "$DRY_RUN" != "true" ]]; then
|
|
if safe_remove "$path" true; then
|
|
removed=1
|
|
fi
|
|
else
|
|
removed=1
|
|
fi
|
|
|
|
if [[ $removed -eq 1 ]]; then
|
|
if [[ "$size" -gt 0 ]]; then
|
|
((total_size_kb += size))
|
|
fi
|
|
((total_count += 1))
|
|
removed_any=1
|
|
else
|
|
if [[ -e "$path" && "$DRY_RUN" != "true" ]]; then
|
|
((removal_failed_count++))
|
|
fi
|
|
fi
|
|
fi
|
|
((idx++))
|
|
done
|
|
fi
|
|
|
|
else
|
|
# Start spinner for cleaning phase (small batch)
|
|
if [[ "$DRY_RUN" != "true" && ${#existing_paths[@]} -gt 0 && -t 1 ]]; then
|
|
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Cleaning..."
|
|
cleaning_spinner_started=true
|
|
fi
|
|
local idx=0
|
|
if [[ ${#existing_paths[@]} -gt 0 ]]; then
|
|
for path in "${existing_paths[@]}"; do
|
|
local size_kb
|
|
size_kb=$(get_cleanup_path_size_kb "$path")
|
|
[[ ! "$size_kb" =~ ^[0-9]+$ ]] && size_kb=0
|
|
|
|
local removed=0
|
|
if [[ "$DRY_RUN" != "true" ]]; then
|
|
if safe_remove "$path" true; then
|
|
removed=1
|
|
fi
|
|
else
|
|
removed=1
|
|
fi
|
|
|
|
if [[ $removed -eq 1 ]]; then
|
|
if [[ "$size_kb" -gt 0 ]]; then
|
|
((total_size_kb += size_kb))
|
|
fi
|
|
((total_count += 1))
|
|
removed_any=1
|
|
else
|
|
if [[ -e "$path" && "$DRY_RUN" != "true" ]]; then
|
|
((removal_failed_count++))
|
|
fi
|
|
fi
|
|
((idx++))
|
|
done
|
|
fi
|
|
fi
|
|
|
|
if [[ "$show_spinner" == "true" || "$cleaning_spinner_started" == "true" ]]; then
|
|
stop_inline_spinner
|
|
fi
|
|
|
|
local permission_end=${MOLE_PERMISSION_DENIED_COUNT:-0}
|
|
# Track permission failures in debug output (avoid noisy user warnings).
|
|
if [[ $permission_end -gt $permission_start && $removed_any -eq 0 ]]; then
|
|
debug_log "Permission denied while cleaning: $description"
|
|
fi
|
|
if [[ $removal_failed_count -gt 0 && "$DRY_RUN" != "true" ]]; then
|
|
debug_log "Skipped $removal_failed_count items, permission denied or in use, for: $description"
|
|
fi
|
|
|
|
if [[ $removed_any -eq 1 ]]; then
|
|
# Stop spinner before output
|
|
stop_section_spinner
|
|
|
|
local size_human=$(bytes_to_human "$((total_size_kb * 1024))")
|
|
|
|
local label="$description"
|
|
if [[ ${#targets[@]} -gt 1 ]]; then
|
|
label+=" ${#targets[@]} items"
|
|
fi
|
|
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label${NC}, ${YELLOW}$size_human dry${NC}"
|
|
|
|
local paths_temp=$(create_temp_file)
|
|
|
|
idx=0
|
|
if [[ ${#existing_paths[@]} -gt 0 ]]; then
|
|
for path in "${existing_paths[@]}"; do
|
|
local size=0
|
|
|
|
if [[ -n "${temp_dir:-}" && -f "$temp_dir/result_${idx}" ]]; then
|
|
read -r size count < "$temp_dir/result_${idx}" 2> /dev/null || true
|
|
else
|
|
size=$(get_cleanup_path_size_kb "$path" 2> /dev/null || echo "0")
|
|
fi
|
|
|
|
[[ "$size" == "0" || -z "$size" ]] && {
|
|
((idx++))
|
|
continue
|
|
}
|
|
|
|
echo "$(dirname "$path")|$size|$path" >> "$paths_temp"
|
|
((idx++))
|
|
done
|
|
fi
|
|
|
|
# Group dry-run paths by parent for a compact export list.
|
|
if [[ -f "$paths_temp" && -s "$paths_temp" ]]; then
|
|
sort -t'|' -k1,1 "$paths_temp" | awk -F'|' '
|
|
{
|
|
parent = $1
|
|
size = $2
|
|
path = $3
|
|
|
|
parent_size[parent] += size
|
|
if (parent_count[parent] == 0) {
|
|
parent_first[parent] = path
|
|
}
|
|
parent_count[parent]++
|
|
}
|
|
END {
|
|
for (parent in parent_size) {
|
|
if (parent_count[parent] > 1) {
|
|
printf "%s|%d|%d\n", parent, parent_size[parent], parent_count[parent]
|
|
} else {
|
|
printf "%s|%d|1\n", parent_first[parent], parent_size[parent]
|
|
}
|
|
}
|
|
}
|
|
' | while IFS='|' read -r display_path total_size child_count; do
|
|
local size_human=$(bytes_to_human "$((total_size * 1024))")
|
|
if [[ $child_count -gt 1 ]]; then
|
|
echo "$display_path # $size_human, $child_count items" >> "$EXPORT_LIST_FILE"
|
|
else
|
|
echo "$display_path # $size_human" >> "$EXPORT_LIST_FILE"
|
|
fi
|
|
done
|
|
fi
|
|
else
|
|
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$size_human${NC}"
|
|
fi
|
|
((files_cleaned += total_count))
|
|
((total_size_cleaned += total_size_kb))
|
|
((total_items++))
|
|
note_activity
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
start_cleanup() {
|
|
# Set current command for operation logging
|
|
export MOLE_CURRENT_COMMAND="clean"
|
|
log_operation_session_start "clean"
|
|
|
|
if [[ -t 1 ]]; then
|
|
printf '\033[2J\033[H'
|
|
fi
|
|
printf '\n'
|
|
echo -e "${PURPLE_BOLD}Clean Your Mac${NC}"
|
|
echo ""
|
|
|
|
if [[ "$DRY_RUN" != "true" && -t 0 ]]; then
|
|
echo -e "${GRAY}${ICON_WARNING} Use --dry-run to preview, --whitelist to manage protected paths${NC}"
|
|
fi
|
|
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions"
|
|
echo ""
|
|
|
|
ensure_user_file "$EXPORT_LIST_FILE"
|
|
cat > "$EXPORT_LIST_FILE" << EOF
|
|
# Mole Cleanup Preview - $(date '+%Y-%m-%d %H:%M:%S')
|
|
#
|
|
# How to protect files:
|
|
# 1. Copy any path below to ~/.config/mole/whitelist
|
|
# 2. Run: mo clean --whitelist
|
|
#
|
|
# Example:
|
|
# /Users/*/Library/Caches/com.example.app
|
|
#
|
|
|
|
EOF
|
|
|
|
# Preview system section when sudo is already cached (no password prompt).
|
|
if sudo -n true 2> /dev/null; then
|
|
SYSTEM_CLEAN=true
|
|
echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access available, system preview included"
|
|
echo ""
|
|
else
|
|
SYSTEM_CLEAN=false
|
|
echo -e "${GRAY}${ICON_WARNING} System caches need sudo, run ${NC}sudo -v && mo clean --dry-run${GRAY} for full preview${NC}"
|
|
echo ""
|
|
fi
|
|
return
|
|
fi
|
|
|
|
if [[ -t 0 ]]; then
|
|
if sudo -n true 2> /dev/null; then
|
|
SYSTEM_CLEAN=true
|
|
echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access already available"
|
|
echo ""
|
|
else
|
|
echo -ne "${PURPLE}${ICON_ARROW}${NC} System caches need sudo. ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: "
|
|
|
|
local choice
|
|
choice=$(read_key)
|
|
|
|
# ESC/Q aborts, Space skips, Enter enables system cleanup.
|
|
if [[ "$choice" == "QUIT" ]]; then
|
|
echo -e " ${GRAY}Canceled${NC}"
|
|
exit 0
|
|
fi
|
|
|
|
if [[ "$choice" == "SPACE" ]]; then
|
|
echo -e " ${GRAY}Skipped${NC}"
|
|
echo ""
|
|
SYSTEM_CLEAN=false
|
|
elif [[ "$choice" == "ENTER" ]]; then
|
|
printf "\r\033[K" # Clear the prompt line
|
|
if ensure_sudo_session "System cleanup requires admin access"; then
|
|
SYSTEM_CLEAN=true
|
|
echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access granted"
|
|
echo ""
|
|
else
|
|
SYSTEM_CLEAN=false
|
|
echo ""
|
|
echo -e "${YELLOW}Authentication failed${NC}, continuing with user-level cleanup"
|
|
fi
|
|
else
|
|
SYSTEM_CLEAN=false
|
|
echo -e " ${GRAY}Skipped${NC}"
|
|
echo ""
|
|
fi
|
|
fi
|
|
else
|
|
echo ""
|
|
echo "Running in non-interactive mode"
|
|
if sudo -n true 2> /dev/null; then
|
|
SYSTEM_CLEAN=true
|
|
echo " ${ICON_LIST} System-level cleanup enabled, sudo session active"
|
|
else
|
|
SYSTEM_CLEAN=false
|
|
echo " ${ICON_LIST} System-level cleanup skipped, requires sudo"
|
|
fi
|
|
echo " ${ICON_LIST} User-level cleanup will proceed automatically"
|
|
echo ""
|
|
fi
|
|
}
|
|
|
|
perform_cleanup() {
|
|
# Test mode skips expensive scans and returns minimal output.
|
|
local test_mode_enabled=false
|
|
if [[ "${MOLE_TEST_MODE:-0}" == "1" ]]; then
|
|
test_mode_enabled=true
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions"
|
|
echo ""
|
|
fi
|
|
echo -e "${GREEN}${ICON_LIST}${NC} User app cache"
|
|
if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then
|
|
local -a expanded_defaults
|
|
expanded_defaults=()
|
|
for default in "${DEFAULT_WHITELIST_PATTERNS[@]}"; do
|
|
expanded_defaults+=("${default/#\~/$HOME}")
|
|
done
|
|
local has_custom=false
|
|
for pattern in "${WHITELIST_PATTERNS[@]}"; do
|
|
local is_default=false
|
|
local normalized_pattern="${pattern%/}"
|
|
for default in "${expanded_defaults[@]}"; do
|
|
local normalized_default="${default%/}"
|
|
[[ "$normalized_pattern" == "$normalized_default" ]] && is_default=true && break
|
|
done
|
|
[[ "$is_default" == "false" ]] && has_custom=true && break
|
|
done
|
|
[[ "$has_custom" == "true" ]] && echo -e "${GREEN}${ICON_SUCCESS}${NC} Protected items found"
|
|
fi
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
echo ""
|
|
echo "Potential space: 0.00GB"
|
|
fi
|
|
total_items=1
|
|
files_cleaned=0
|
|
total_size_cleaned=0
|
|
fi
|
|
|
|
if [[ "$test_mode_enabled" == "false" ]]; then
|
|
echo -e "${BLUE}${ICON_ADMIN}${NC} $(detect_architecture) | Free space: $(get_free_space)"
|
|
fi
|
|
|
|
if [[ "$test_mode_enabled" == "true" ]]; then
|
|
local summary_heading="Test mode complete"
|
|
local -a summary_details
|
|
summary_details=()
|
|
summary_details+=("Test mode - no actual cleanup performed")
|
|
print_summary_block "$summary_heading" "${summary_details[@]}"
|
|
printf '\n'
|
|
return 0
|
|
fi
|
|
|
|
# Pre-check TCC permissions to avoid mid-run prompts.
|
|
check_tcc_permissions
|
|
|
|
if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then
|
|
local predefined_count=0
|
|
local custom_count=0
|
|
|
|
for pattern in "${WHITELIST_PATTERNS[@]}"; do
|
|
local is_predefined=false
|
|
for default in "${DEFAULT_WHITELIST_PATTERNS[@]}"; do
|
|
local expanded_default="${default/#\~/$HOME}"
|
|
if [[ "$pattern" == "$expanded_default" ]]; then
|
|
is_predefined=true
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [[ "$is_predefined" == "true" ]]; then
|
|
((predefined_count++))
|
|
else
|
|
((custom_count++))
|
|
fi
|
|
done
|
|
|
|
if [[ $custom_count -gt 0 || $predefined_count -gt 0 ]]; then
|
|
local summary=""
|
|
[[ $predefined_count -gt 0 ]] && summary+="$predefined_count core"
|
|
[[ $custom_count -gt 0 && $predefined_count -gt 0 ]] && summary+=" + "
|
|
[[ $custom_count -gt 0 ]] && summary+="$custom_count custom"
|
|
summary+=" patterns active"
|
|
|
|
echo -e "${BLUE}${ICON_SUCCESS}${NC} Whitelist: $summary"
|
|
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
for pattern in "${WHITELIST_PATTERNS[@]}"; do
|
|
[[ "$pattern" == "$FINDER_METADATA_SENTINEL" ]] && continue
|
|
echo -e " ${GRAY}${ICON_SUBLIST}${NC} ${GRAY}${pattern}${NC}"
|
|
done
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [[ -t 1 && "$DRY_RUN" != "true" ]]; then
|
|
local fda_status=0
|
|
has_full_disk_access
|
|
fda_status=$?
|
|
if [[ $fda_status -eq 1 ]]; then
|
|
echo ""
|
|
echo -e "${GRAY}${ICON_REVIEW}${NC} ${GRAY}Grant Full Disk Access to your terminal in System Settings for best results${NC}"
|
|
fi
|
|
fi
|
|
|
|
total_items=0
|
|
files_cleaned=0
|
|
total_size_cleaned=0
|
|
|
|
local had_errexit=0
|
|
[[ $- == *e* ]] && had_errexit=1
|
|
|
|
# Allow per-section failures without aborting the full run.
|
|
set +e
|
|
|
|
# ===== 1. System =====
|
|
if [[ "$SYSTEM_CLEAN" == "true" ]]; then
|
|
start_section "System"
|
|
clean_deep_system
|
|
clean_local_snapshots
|
|
end_section
|
|
fi
|
|
|
|
if [[ ${#WHITELIST_WARNINGS[@]} -gt 0 ]]; then
|
|
echo ""
|
|
for warning in "${WHITELIST_WARNINGS[@]}"; do
|
|
echo -e " ${GRAY}${ICON_WARNING}${NC} Whitelist: $warning"
|
|
done
|
|
fi
|
|
|
|
# ===== 2. User essentials =====
|
|
start_section "User essentials"
|
|
clean_user_essentials
|
|
clean_finder_metadata
|
|
scan_external_volumes
|
|
end_section
|
|
|
|
# ===== 3. App caches (merged sandboxed and standard app caches) =====
|
|
start_section "App caches"
|
|
clean_app_caches
|
|
end_section
|
|
|
|
# ===== 4. Browsers =====
|
|
start_section "Browsers"
|
|
clean_browsers
|
|
end_section
|
|
|
|
# ===== 5. Cloud & Office =====
|
|
start_section "Cloud & Office"
|
|
clean_cloud_storage
|
|
clean_office_applications
|
|
end_section
|
|
|
|
# ===== 6. Developer tools (merged CLI and GUI tooling) =====
|
|
start_section "Developer tools"
|
|
clean_developer_tools
|
|
end_section
|
|
|
|
# ===== 7. Applications =====
|
|
start_section "Applications"
|
|
clean_user_gui_applications
|
|
end_section
|
|
|
|
# ===== 8. Virtualization =====
|
|
start_section "Virtualization"
|
|
clean_virtualization_tools
|
|
end_section
|
|
|
|
# ===== 9. Application Support =====
|
|
start_section "Application Support"
|
|
clean_application_support_logs
|
|
end_section
|
|
|
|
# ===== 10. Orphaned data =====
|
|
start_section "Orphaned data"
|
|
clean_orphaned_app_data
|
|
clean_orphaned_system_services
|
|
clean_orphaned_launch_agents
|
|
end_section
|
|
|
|
# ===== 11. Apple Silicon =====
|
|
clean_apple_silicon_caches
|
|
|
|
# ===== 12. Device backups =====
|
|
start_section "Device backups"
|
|
check_ios_device_backups
|
|
end_section
|
|
|
|
# ===== 13. Time Machine =====
|
|
start_section "Time Machine"
|
|
clean_time_machine_failed_backups
|
|
end_section
|
|
|
|
# ===== 14. Large files =====
|
|
start_section "Large files"
|
|
check_large_file_candidates
|
|
end_section
|
|
|
|
# ===== 15. System Data clues =====
|
|
start_section "System Data clues"
|
|
show_system_data_hint_notice
|
|
end_section
|
|
|
|
# ===== 16. Project artifacts =====
|
|
start_section "Project artifacts"
|
|
show_project_artifact_hint_notice
|
|
end_section
|
|
|
|
# ===== Final summary =====
|
|
echo ""
|
|
|
|
local summary_heading=""
|
|
local summary_status="success"
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
summary_heading="Dry run complete - no changes made"
|
|
else
|
|
summary_heading="Cleanup complete"
|
|
fi
|
|
|
|
local -a summary_details=()
|
|
|
|
if [[ $total_size_cleaned -gt 0 ]]; then
|
|
local freed_gb
|
|
freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}')
|
|
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
local stats="Potential space: ${GREEN}${freed_gb}GB${NC}"
|
|
[[ $files_cleaned -gt 0 ]] && stats+=" | Items: $files_cleaned"
|
|
[[ $total_items -gt 0 ]] && stats+=" | Categories: $total_items"
|
|
summary_details+=("$stats")
|
|
|
|
{
|
|
echo ""
|
|
echo "# ============================================"
|
|
echo "# Summary"
|
|
echo "# ============================================"
|
|
echo "# Potential cleanup: ${freed_gb}GB"
|
|
echo "# Items: $files_cleaned"
|
|
echo "# Categories: $total_items"
|
|
} >> "$EXPORT_LIST_FILE"
|
|
|
|
summary_details+=("Detailed file list: ${GRAY}$EXPORT_LIST_FILE${NC}")
|
|
summary_details+=("Use ${GRAY}mo clean --whitelist${NC} to add protection rules")
|
|
else
|
|
local summary_line="Space freed: ${GREEN}${freed_gb}GB${NC}"
|
|
|
|
if [[ $files_cleaned -gt 0 && $total_items -gt 0 ]]; then
|
|
summary_line+=" | Items cleaned: $files_cleaned | Categories: $total_items"
|
|
elif [[ $files_cleaned -gt 0 ]]; then
|
|
summary_line+=" | Items cleaned: $files_cleaned"
|
|
elif [[ $total_items -gt 0 ]]; then
|
|
summary_line+=" | Categories: $total_items"
|
|
fi
|
|
|
|
summary_details+=("$summary_line")
|
|
|
|
if [[ $(echo "$freed_gb" | awk '{print ($1 >= 1) ? 1 : 0}') -eq 1 ]]; then
|
|
local movies
|
|
movies=$(echo "$freed_gb" | awk '{printf "%.0f", $1/4.5}')
|
|
if [[ $movies -gt 0 ]]; then
|
|
if [[ $movies -eq 1 ]]; then
|
|
summary_details+=("Equivalent to ~$movies 4K movie of storage.")
|
|
else
|
|
summary_details+=("Equivalent to ~$movies 4K movies of storage.")
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
local final_free_space=$(get_free_space)
|
|
summary_details+=("Free space now: $final_free_space")
|
|
fi
|
|
else
|
|
summary_status="info"
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
summary_details+=("No significant reclaimable space detected, system already clean.")
|
|
else
|
|
summary_details+=("System was already clean; no additional space freed.")
|
|
fi
|
|
summary_details+=("Free space now: $(get_free_space)")
|
|
fi
|
|
|
|
if [[ $had_errexit -eq 1 ]]; then
|
|
set -e
|
|
fi
|
|
|
|
# Log session end with summary
|
|
log_operation_session_end "clean" "$files_cleaned" "$total_size_cleaned"
|
|
|
|
print_summary_block "$summary_heading" "${summary_details[@]}"
|
|
printf '\n'
|
|
}
|
|
|
|
main() {
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
"--help" | "-h")
|
|
show_clean_help
|
|
exit 0
|
|
;;
|
|
"--debug")
|
|
export MO_DEBUG=1
|
|
;;
|
|
"--dry-run" | "-n")
|
|
DRY_RUN=true
|
|
export MOLE_DRY_RUN=1
|
|
;;
|
|
"--whitelist")
|
|
source "$SCRIPT_DIR/../lib/manage/whitelist.sh"
|
|
manage_whitelist "clean"
|
|
exit 0
|
|
;;
|
|
esac
|
|
done
|
|
|
|
start_cleanup
|
|
hide_cursor
|
|
perform_cleanup
|
|
show_cursor
|
|
exit 0
|
|
}
|
|
|
|
main "$@"
|