From af03452f6d91aac8627eb40cc60077c0ed31dd51 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 18 Dec 2025 17:02:04 +0800 Subject: [PATCH] feat: Enhance clean and optimize operations with new configuration constants --- SECURITY_AUDIT.md | 10 +- bin/clean.sh | 35 +- bin/optimize.sh | 29 +- lib/clean/app_caches.sh | 1 - lib/clean/caches.sh | 52 --- lib/clean/dev.sh | 3 +- lib/clean/project.sh | 2 +- lib/clean/system.sh | 139 +++++-- lib/clean/user.sh | 225 ++++++----- lib/core/app_protection.sh | 379 +++++++----------- lib/core/base.sh | 1 + lib/core/common.sh | 2 +- lib/core/file_ops.sh | 8 +- lib/optimize/tasks.sh | 81 +++- tests/purge.bats | 5 +- tests/system_maintenance.bats | 2 +- .../.config/mole/clean-list.txt | 13 + 17 files changed, 504 insertions(+), 483 deletions(-) create mode 100644 tests/tmp-clean-home.EmChvN/.config/mole/clean-list.txt diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 83b4b4b..2cfe00d 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -1,8 +1,8 @@ # Mole Security Audit Report -**Date:** December 14, 2025 +**Date:** December 18, 2025 -**Audited Version:** Current `main` branch (V1.12.25) +**Audited Version:** Current `main` branch (V1.13.9) **Status:** Passed @@ -19,6 +19,7 @@ Mole's automated shell-based operations (Clean, Optimize, Uninstall) do not exec - **Absolute Path Enforcement**: Relative paths (e.g., `../foo`) are strictly rejected to prevent path traversal attacks. - **Control Character Filtering**: Paths containing hidden control characters or newlines are blocked. - **Empty Variable Protection**: Guards against shell scripting errors where an empty variable could result in `rm -rf /`. + - **Secure Temporary Workspaces**: Temporary directories are created using `mktemp -d` with restricted permissions (700) to ensure process isolation and prevent data leakage. - **Layer 2: The "Iron Dome" (Path Validation)** A centralized validation logic explicitly blocks operations on critical system hierarchies within the shell core, even with `sudo` privileges: @@ -59,6 +60,9 @@ Mole's "Smart Uninstall" and orphan detection (`lib/clean/apps.sh`) are intentio - **System Integrity Protection (SIP) Awareness** Mole respects macOS SIP. It detects if SIP is enabled and automatically skips protected directories (like `/Library/Updates`) to avoid triggering permission errors. +- **Spotlight Preservation (Critical Fix)** + User-level Spotlight caches (`~/Library/Metadata/CoreSpotlight`) are strictly excluded from automated cleaning. This prevents corruption of System Settings and ensures stable UI performance for indexed searches. + - **Time Machine Preservation** Before cleaning failed backups, Mole checks for the `backupd` process. If a backup is currently running, the cleanup task is strictly **aborted** to prevent data corruption. @@ -77,6 +81,7 @@ We anticipate that scripts can be interrupted (e.g., power loss, `Ctrl+C`). - **Network Interface Reset**: Wi-Fi and AirDrop resets use **atomic execution blocks**. - **Swap Clearing**: Swap files are reset by securely restarting the `dynamic_pager` daemon. We intentionally avoid manual `rm` operations on swap files to prevent instability during high memory pressure. +- **Unresponsive Volume Protection**: During volume scanning, Mole uses `run_with_timeout` and filesystem type validation (`nfs`, `smbfs`, etc.) to prevent the script from hanging on unresponsive or slow network mounts. ## 5. User Control & Transparency @@ -90,6 +95,7 @@ We anticipate that scripts can be interrupted (e.g., power loss, `Ctrl+C`). - `plutil`: Used to validate `.plist` integrity. - `tmutil`: Used for safe interaction with Time Machine. - `dscacheutil`: Used for system-compliant cache rebuilding. + - `bioutil`: Used for reliable and hardware-correct Touch ID status detection. - **Go Dependencies (Interactive Tools)** The compiled Go binary (`analyze-go`) includes the following libraries: diff --git a/bin/clean.sh b/bin/clean.sh index 4aeb5ae..0b84add 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -261,6 +261,7 @@ safe_clean() { local total_paths=${#existing_paths[@]} if [[ -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning $total_paths items..."; fi local temp_dir + # create_temp_dir uses mktemp -d for secure temporary directory creation temp_dir=$(create_temp_dir) # Parallel processing (bash 3.2 compatible) @@ -498,6 +499,7 @@ EOF # Check for cancel (ESC or Q) if [[ "$choice" == "QUIT" ]]; then + echo -e " ${GRAY}Canceled${NC}" exit 0 fi @@ -521,6 +523,8 @@ EOF else # Other keys (including arrow keys) = skip, no message needed SYSTEM_CLEAN=false + echo -e " ${GRAY}Skipped${NC}" + echo "" fi else SYSTEM_CLEAN=false @@ -598,6 +602,7 @@ perform_cleanup() { start_section "User essentials" # User essentials cleanup (delegated to clean_user_data module) clean_user_essentials + scan_external_volumes end_section start_section "Finder metadata" @@ -683,9 +688,9 @@ perform_cleanup() { check_ios_device_backups end_section - # ===== 15. Time Machine failed backups ===== - start_section "Time Machine failed backups" - # Time Machine failed backups cleanup (delegated to clean_system module) + # ===== 15. Time Machine incomplete backups ===== + start_section "Time Machine incomplete backups" + # Time Machine incomplete backups cleanup (delegated to clean_system module) clean_time_machine_failed_backups end_section @@ -729,23 +734,22 @@ perform_cleanup() { summary_details+=("Detailed file list: ${GRAY}$EXPORT_LIST_FILE${NC}") summary_details+=("Use ${GRAY}mo clean --whitelist${NC} to add protection rules") else - summary_details+=("Space freed: ${GREEN}${freed_gb}GB${NC}") - summary_details+=("Free space now: $(get_free_space)") + # Build summary line: Space freed + Items cleaned + local summary_line="Space freed: ${GREEN}${freed_gb}GB${NC}" if [[ $files_cleaned -gt 0 && $total_items -gt 0 ]]; then - local stats="Items cleaned: $files_cleaned | Categories: $total_items" - [[ $whitelist_skipped_count -gt 0 ]] && stats+=" | Protected: $whitelist_skipped_count" - summary_details+=("$stats") + summary_line+=" | Items cleaned: $files_cleaned | Categories: $total_items" + [[ $whitelist_skipped_count -gt 0 ]] && summary_line+=" | Protected: $whitelist_skipped_count" elif [[ $files_cleaned -gt 0 ]]; then - local stats="Items cleaned: $files_cleaned" - [[ $whitelist_skipped_count -gt 0 ]] && stats+=" | Protected: $whitelist_skipped_count" - summary_details+=("$stats") + summary_line+=" | Items cleaned: $files_cleaned" + [[ $whitelist_skipped_count -gt 0 ]] && summary_line+=" | Protected: $whitelist_skipped_count" elif [[ $total_items -gt 0 ]]; then - local stats="Categories: $total_items" - [[ $whitelist_skipped_count -gt 0 ]] && stats+=" | Protected: $whitelist_skipped_count" - summary_details+=("$stats") + summary_line+=" | Categories: $total_items" + [[ $whitelist_skipped_count -gt 0 ]] && summary_line+=" | Protected: $whitelist_skipped_count" 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}') @@ -753,6 +757,9 @@ perform_cleanup() { summary_details+=("Equivalent to ~$movies 4K movies of storage.") fi fi + + # Free space now at the end + summary_details+=("Free space now: $(get_free_space)") fi else summary_status="info" diff --git a/bin/optimize.sh b/bin/optimize.sh index 6ec4a9a..1ee0426 100755 --- a/bin/optimize.sh +++ b/bin/optimize.sh @@ -82,9 +82,8 @@ show_optimization_summary() { local -a summary_details=() # Optimization results - summary_details+=("Optimizations: ${GREEN}${safe_count}${NC} applied, ${YELLOW}${confirm_count}${NC} manual checks") - summary_details+=("Caches refreshed; services restarted; system tuned") - summary_details+=("Updates & security reviewed across system") + summary_details+=("Applied ${GREEN}${safe_count:-0}${NC} optimizations; all system services tuned") + summary_details+=("Updates, security and system health fully reviewed") local summary_line4="" if [[ -n "${AUTO_FIX_SUMMARY:-}" ]]; then @@ -95,15 +94,11 @@ show_optimization_summary() { [[ -n "$detail_join" ]] && summary_line4+=" — ${detail_join}" fi else - summary_line4="Mac should feel faster and more responsive" + summary_line4="Your Mac is now faster and more responsive" fi summary_details+=("$summary_line4") - if [[ -n "${AUTO_FIX_SUMMARY:-}" ]]; then - summary_details+=("$AUTO_FIX_SUMMARY") - fi - - # Fix: Ensure summary is always printed for optimizations + # Ensure summary is always printed for optimizations print_summary_block "$summary_title" "${summary_details[@]}" } @@ -168,10 +163,22 @@ touchid_configured() { } touchid_supported() { + # bioutil is the most reliable way to check for Touch ID hardware/software support if command -v bioutil > /dev/null 2>&1; then - bioutil -r 2> /dev/null | grep -q "Touch ID" && return 0 + # Check if Touch ID is functional and available for any user + if bioutil -r 2> /dev/null | grep -qi "Touch ID"; then + return 0 + fi fi - [[ "$(uname -m)" == "arm64" ]] + + # Fallback: check for Apple Silicon which almost always has Touch ID support + # (except for Mac mini/Studio without a Magic Keyboard with Touch ID) + if [[ "$(uname -m)" == "arm64" ]]; then + # On Apple Silicon, we can check for the presence of the Touch Bar or Touch ID sensor + # but bioutil is generally sufficient. If bioutil failed, we treat arm64 as likely supported. + return 0 + fi + return 1 } cleanup_path() { diff --git a/lib/clean/app_caches.sh b/lib/clean/app_caches.sh index b88e6ff..4f6ed2b 100644 --- a/lib/clean/app_caches.sh +++ b/lib/clean/app_caches.sh @@ -38,7 +38,6 @@ clean_code_editors() { safe_clean ~/Library/Application\ Support/Code/Cache/* "VS Code cache" safe_clean ~/Library/Application\ Support/Code/CachedExtensions/* "VS Code extension cache" safe_clean ~/Library/Application\ Support/Code/CachedData/* "VS Code data cache" - # safe_clean ~/Library/Caches/JetBrains/* "JetBrains cache" safe_clean ~/Library/Caches/com.sublimetext.*/* "Sublime Text cache" } diff --git a/lib/clean/caches.sh b/lib/clean/caches.sh index d9dc45a..000a952 100644 --- a/lib/clean/caches.sh +++ b/lib/clean/caches.sh @@ -193,55 +193,3 @@ clean_project_caches() { [[ -d "$pycache" ]] && safe_clean "$pycache"/* "Python bytecode cache" || true done < "$pycache_tmp_file" } - -# Clean Spotlight user caches -clean_spotlight_caches() { - local cleaned_size=0 - local cleaned_count=0 - - # CoreSpotlight user cache (can grow very large, safe to delete) - local spotlight_cache="$HOME/Library/Metadata/CoreSpotlight" - if [[ -d "$spotlight_cache" ]]; then - local size_kb=$(get_path_size_kb "$spotlight_cache") - if [[ "$size_kb" -gt 0 ]]; then - if [[ "$DRY_RUN" != "true" ]]; then - safe_remove "$spotlight_cache" true && { - ((cleaned_size += size_kb)) - ((cleaned_count++)) - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Spotlight cache ($(bytes_to_human $((size_kb * 1024))))" - note_activity - } - else - ((cleaned_size += size_kb)) - echo -e " ${YELLOW}→${NC} Spotlight cache (would clean $(bytes_to_human $((size_kb * 1024))))" - note_activity - fi - fi - fi - - # Spotlight saved application state - local spotlight_state="$HOME/Library/Saved Application State/com.apple.spotlight.Spotlight.savedState" - if [[ -d "$spotlight_state" ]]; then - local size_kb=$(get_path_size_kb "$spotlight_state") - if [[ "$size_kb" -gt 0 ]]; then - if [[ "$DRY_RUN" != "true" ]]; then - safe_remove "$spotlight_state" true && { - ((cleaned_size += size_kb)) - ((cleaned_count++)) - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Spotlight state ($(bytes_to_human $((size_kb * 1024))))" - note_activity - } - else - ((cleaned_size += size_kb)) - echo -e " ${YELLOW}→${NC} Spotlight state (would clean $(bytes_to_human $((size_kb * 1024))))" - note_activity - fi - fi - fi - - if [[ $cleaned_size -gt 0 ]]; then - ((files_cleaned += cleaned_count)) - ((total_size_cleaned += cleaned_size)) - ((total_items++)) - fi -} diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index d6fae9f..4321f9d 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -245,7 +245,6 @@ clean_dev_api_tools() { # Clean misc dev tools clean_dev_misc() { safe_clean ~/Library/Caches/com.unity3d.*/* "Unity cache" - # safe_clean ~/Library/Caches/com.jetbrains.toolbox/* "JetBrains Toolbox cache" safe_clean ~/Library/Caches/com.mongodb.compass/* "MongoDB Compass cache" safe_clean ~/Library/Caches/com.figma.Desktop/* "Figma cache" safe_clean ~/Library/Caches/com.github.GitHubDesktop/* "GitHub Desktop cache" @@ -314,7 +313,7 @@ clean_developer_tools() { safe_clean "$lock_dir"/* "Homebrew lock files" elif [[ -d "$lock_dir" ]]; then # Directory exists but not writable. Check if empty to avoid noise. - if [[ -n "$(ls -A "$lock_dir" 2> /dev/null)" ]]; then + if find "$lock_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then # Only try sudo ONCE if we really need to, or just skip to avoid spam # Decision: Skip strict system/root owned locks to avoid nag. debug_log "Skipping read-only Homebrew locks in $lock_dir" diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 85f101e..063f476 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -487,7 +487,7 @@ clean_project_artifacts() { for root in "${search_roots[@]}"; do if [[ "$path" == "$root/"* ]]; then # Remove root prefix and get first directory component - local relative_path="${path#$root/}" + local relative_path="${path#"$root"/}" # Extract first directory name echo "$relative_path" | cut -d'/' -f1 return 0 diff --git a/lib/clean/system.sh b/lib/clean/system.sh index 3850a5f..e197222 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -29,14 +29,16 @@ clean_deep_system() { # Clean Library Updates safely - skip if SIP is enabled to avoid error messages # SIP-protected files in /Library/Updates cannot be deleted even with sudo if [[ -d "/Library/Updates" && ! -L "/Library/Updates" ]]; then - if is_sip_enabled; then - # SIP is enabled, skip /Library/Updates entirely to avoid error messages - # These files are system-protected and cannot be removed - : # No-op, silently skip - else + if ! is_sip_enabled; then # SIP is disabled, attempt cleanup with restricted flag check local updates_cleaned=0 while IFS= read -r -d '' item; do + # Validate path format (must be direct child of /Library/Updates) + if [[ -z "$item" ]] || [[ ! "$item" =~ ^/Library/Updates/[^/]+$ ]]; then + debug_log "Skipping malformed path: $item" + continue + fi + # Skip system-protected files (restricted flag) local item_flags item_flags=$(command stat -f%Sf "$item" 2> /dev/null || echo "") @@ -81,12 +83,22 @@ clean_deep_system() { MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning system caches..." fi local code_sign_cleaned=0 + local found_count=0 + + # Stream processing with progress updates (efficient for large directories) + # Reduce timeout to 5s for faster completion when no caches exist while IFS= read -r -d '' cache_dir; do - debug_log "Found code sign cache: $cache_dir" if safe_remove "$cache_dir" true; then ((code_sign_cleaned++)) fi - done < <(find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true) + ((found_count++)) + + # Update spinner every 50 items to show progress + if [[ -t 1 ]] && ((found_count % 50 == 0)); then + stop_inline_spinner + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning system caches... ($found_count found)" + fi + done < <(run_with_timeout 5 command find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true) if [[ -t 1 ]]; then stop_inline_spinner; fi @@ -103,52 +115,89 @@ clean_deep_system() { log_success "Power logs" } -# Clean Time Machine failed backups +# Clean Time Machine incomplete backups clean_time_machine_failed_backups() { local tm_cleaned=0 - # Check if Time Machine is configured - if command -v tmutil > /dev/null 2>&1; then - if tmutil destinationinfo 2>&1 | grep -q "No destinations configured"; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No failed Time Machine backups found" - return 0 + # Check if tmutil is available + if ! command -v tmutil > /dev/null 2>&1; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" + return 0 + fi + + # Start spinner early (before potentially slow tmutil command) + if [[ -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Time Machine configuration..." + fi + local spinner_active=true + + # Check if Time Machine is configured (with short timeout for faster response) + local tm_info + tm_info=$(run_with_timeout 2 tmutil destinationinfo 2>&1 || echo "failed") + if [[ "$tm_info" == *"No destinations configured"* || "$tm_info" == "failed" ]]; then + if [[ "$spinner_active" == "true" && -t 1 ]]; then + stop_inline_spinner fi + echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" + return 0 fi if [[ ! -d "/Volumes" ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No failed Time Machine backups found" + if [[ "$spinner_active" == "true" && -t 1 ]]; then + stop_inline_spinner + fi + echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" return 0 fi # Skip if backup is running if pgrep -x "backupd" > /dev/null 2>&1; then + if [[ "$spinner_active" == "true" && -t 1 ]]; then + stop_inline_spinner + fi echo -e " ${YELLOW}!${NC} Time Machine backup in progress, skipping cleanup" return 0 fi + # Update spinner message for volume scanning + if [[ "$spinner_active" == "true" && -t 1 ]]; then + stop_inline_spinner + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking backup volumes..." + fi + + # Fast pre-scan: check which volumes have Backups.backupdb (avoid expensive tmutil checks) + local -a backup_volumes=() for volume in /Volumes/*; do [[ -d "$volume" ]] || continue - - # Skip system and network volumes [[ "$volume" == "/Volumes/MacintoshHD" || "$volume" == "/" ]] && continue - - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning backup volumes..." - fi - - # Skip if volume is a symlink (security check) [[ -L "$volume" ]] && continue - # Check if this is a Time Machine destination - if command -v tmutil > /dev/null 2>&1; then - if ! tmutil destinationinfo 2> /dev/null | grep -q "$(basename "$volume")"; then - continue - fi + # Quick check: does this volume have backup directories? + if [[ -d "$volume/Backups.backupdb" ]] || [[ -d "$volume/.MobileBackups" ]]; then + backup_volumes+=("$volume") fi + done - local fs_type=$(command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}') + # If no backup volumes found, stop spinner and return + if [[ ${#backup_volumes[@]} -eq 0 ]]; then + if [[ "$spinner_active" == "true" && -t 1 ]]; then + stop_inline_spinner + fi + echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" + return 0 + fi + + # Update spinner message: we have potential backup volumes, now scan them + if [[ "$spinner_active" == "true" && -t 1 ]]; then + stop_inline_spinner + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning backup volumes..." + fi + for volume in "${backup_volumes[@]}"; do + # Skip network volumes (quick check) + local fs_type + fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "unknown") case "$fs_type" in - nfs | smbfs | afpfs | cifs | webdav) continue ;; + nfs | smbfs | afpfs | cifs | webdav | unknown) continue ;; esac # HFS+ style backups (Backups.backupdb) @@ -157,7 +206,7 @@ clean_time_machine_failed_backups() { while IFS= read -r inprogress_file; do [[ -d "$inprogress_file" ]] || continue - # Only delete old failed backups (safety window) + # Only delete old incomplete backups (safety window) local file_mtime=$(get_file_mtime "$inprogress_file") local current_time=$(date +%s) local hours_old=$(((current_time - file_mtime) / 3600)) @@ -169,11 +218,17 @@ clean_time_machine_failed_backups() { local size_kb=$(get_path_size_kb "$inprogress_file") [[ "$size_kb" -le 0 ]] && continue + # Stop spinner before first output + if [[ "$spinner_active" == "true" ]]; then + if [[ -t 1 ]]; then stop_inline_spinner; fi + spinner_active=false + fi + local backup_name=$(basename "$inprogress_file") local size_human=$(bytes_to_human "$((size_kb * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}→${NC} Failed backup: $backup_name ${YELLOW}($size_human dry)${NC}" + echo -e " ${YELLOW}→${NC} Incomplete backup: $backup_name ${YELLOW}($size_human dry)${NC}" ((tm_cleaned++)) note_activity continue @@ -186,7 +241,7 @@ clean_time_machine_failed_backups() { fi if tmutil delete "$inprogress_file" 2> /dev/null; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Failed backup: $backup_name ${GREEN}($size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name ${GREEN}($size_human)${NC}" ((tm_cleaned++)) ((files_cleaned++)) ((total_size_cleaned += size_kb)) @@ -211,7 +266,7 @@ clean_time_machine_failed_backups() { while IFS= read -r inprogress_file; do [[ -d "$inprogress_file" ]] || continue - # Only delete old failed backups (safety window) + # Only delete old incomplete backups (safety window) local file_mtime=$(get_file_mtime "$inprogress_file") local current_time=$(date +%s) local hours_old=$(((current_time - file_mtime) / 3600)) @@ -223,11 +278,17 @@ clean_time_machine_failed_backups() { local size_kb=$(get_path_size_kb "$inprogress_file") [[ "$size_kb" -le 0 ]] && continue + # Stop spinner before first output + if [[ "$spinner_active" == "true" ]]; then + if [[ -t 1 ]]; then stop_inline_spinner; fi + spinner_active=false + fi + local backup_name=$(basename "$inprogress_file") local size_human=$(bytes_to_human "$((size_kb * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}→${NC} Failed APFS backup in $bundle_name: $backup_name ${YELLOW}($size_human dry)${NC}" + echo -e " ${YELLOW}→${NC} Incomplete APFS backup in $bundle_name: $backup_name ${YELLOW}($size_human dry)${NC}" ((tm_cleaned++)) note_activity continue @@ -239,7 +300,7 @@ clean_time_machine_failed_backups() { fi if tmutil delete "$inprogress_file" 2> /dev/null; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Failed APFS backup in $bundle_name: $backup_name ${GREEN}($size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name ${GREEN}($size_human)${NC}" ((tm_cleaned++)) ((files_cleaned++)) ((total_size_cleaned += size_kb)) @@ -251,11 +312,15 @@ clean_time_machine_failed_backups() { done < <(run_with_timeout 15 find "$mounted_path" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) fi done - if [[ -t 1 ]]; then stop_inline_spinner; fi done + # Stop spinner if still active (no backups found) + if [[ "$spinner_active" == "true" ]]; then + if [[ -t 1 ]]; then stop_inline_spinner; fi + fi + if [[ $tm_cleaned -eq 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} No failed Time Machine backups found" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" fi } diff --git a/lib/clean/user.sh b/lib/clean/user.sh index d1a7c23..04057f8 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -3,38 +3,90 @@ set -euo pipefail -# Clean user essentials (caches, logs, trash, crash reports) +# Clean user essentials (caches, logs, trash) clean_user_essentials() { safe_clean ~/Library/Caches/* "User app cache" safe_clean ~/Library/Logs/* "User app logs" safe_clean ~/.Trash/* "Trash" +} - # Empty trash on mounted volumes - if [[ -d "/Volumes" && "$DRY_RUN" != "true" ]]; then - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning external volumes..." - fi - for volume in /Volumes/*; do - [[ -d "$volume" && -d "$volume/.Trashes" && -w "$volume" ]] || continue +# Helper: Scan external volumes for cleanup (Trash & DS_Store) +scan_external_volumes() { + [[ -d "/Volumes" ]] || return 0 - # Skip network volumes - local fs_type=$(command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}') - case "$fs_type" in - nfs | smbfs | afpfs | cifs | webdav) continue ;; - esac + # Fast pre-check: count non-system external volumes without expensive operations + local -a candidate_volumes=() + for volume in /Volumes/*; do + # Basic checks (directory, writable, not a symlink) + [[ -d "$volume" && -w "$volume" && ! -L "$volume" ]] || continue - # Verify volume is mounted and not a symlink - mount | grep -q "on $volume " || continue - [[ -L "$volume/.Trashes" ]] && continue + # Skip system root if it appears in /Volumes + [[ "$volume" == "/" || "$volume" == "/Volumes/Macintosh HD" ]] && continue + candidate_volumes+=("$volume") + done + + # If no external volumes found, return immediately (zero overhead) + local volume_count=${#candidate_volumes[@]} + [[ $volume_count -eq 0 ]] && return 0 + + # We have external volumes, now perform full scan + if [[ -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning $volume_count external volume(s)..." + fi + + for volume in "${candidate_volumes[@]}"; do + # Skip network volumes with short timeout (reduced from 2s to 1s) + local fs_type="" + fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "unknown") + case "$fs_type" in + nfs | smbfs | afpfs | cifs | webdav | unknown) continue ;; + esac + + # Verify volume is actually mounted (reduced timeout from 2s to 1s) + run_with_timeout 1 mount | grep -q "on $volume " || continue + + # 1. Clean Trash on volume + if [[ -d "$volume/.Trashes" && "$DRY_RUN" != "true" ]]; then # Safely iterate and remove each item while IFS= read -r -d '' item; do safe_remove "$item" true || true done < <(command find "$volume/.Trashes" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) - done - if [[ -t 1 ]]; then stop_inline_spinner; fi + fi + + # 2. Clean .DS_Store + if [[ "$PROTECT_FINDER_METADATA" != "true" ]]; then + clean_ds_store_tree "$volume" "$(basename "$volume") volume (.DS_Store)" + fi + done + + if [[ -t 1 ]]; then stop_inline_spinner; fi +} + +# Clean Finder metadata (.DS_Store files) +clean_finder_metadata() { + if [[ "$PROTECT_FINDER_METADATA" == "true" ]]; then + note_activity + echo -e " ${GRAY}⊘${NC} Finder metadata (protected)" + return fi + clean_ds_store_tree "$HOME" "Home directory (.DS_Store)" +} + +# Clean macOS system caches +clean_macos_system_caches() { + safe_clean ~/Library/Saved\ Application\ State/* "Saved application states" + + # REMOVED: Spotlight cache cleanup can cause system UI issues + # Spotlight indexes should be managed by macOS automatically + # safe_clean ~/Library/Caches/com.apple.spotlight "Spotlight cache" + + safe_clean ~/Library/Caches/com.apple.photoanalysisd "Photo analysis cache" + safe_clean ~/Library/Caches/com.apple.akd "Apple ID cache" + safe_clean ~/Library/Caches/com.apple.WebKit.Networking/* "WebKit network cache" + + # Extra user items safe_clean ~/Library/DiagnosticReports/* "Diagnostic reports" safe_clean ~/Library/Caches/com.apple.QuickLook.thumbnailcache "QuickLook thumbnails" safe_clean ~/Library/Caches/Quick\ Look/* "QuickLook cache" @@ -54,47 +106,6 @@ clean_user_essentials() { safe_clean ~/Library/Application\ Support/AddressBook/Sources/*/Photos.cache "Address Book photo cache" } -# Clean Finder metadata (.DS_Store files) -clean_finder_metadata() { - if [[ "$PROTECT_FINDER_METADATA" == "true" ]]; then - note_activity - echo -e " ${GRAY}${ICON_SUCCESS}${NC} Finder metadata (whitelisted)" - else - clean_ds_store_tree "$HOME" "Home directory (.DS_Store)" - - if [[ -d "/Volumes" ]]; then - for volume in /Volumes/*; do - [[ -d "$volume" && -w "$volume" ]] || continue - - local fs_type="" - fs_type=$(command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}') - case "$fs_type" in - nfs | smbfs | afpfs | cifs | webdav) continue ;; - esac - - clean_ds_store_tree "$volume" "$(basename "$volume") volume (.DS_Store)" - done - fi - fi -} - -# Clean macOS system caches -clean_macos_system_caches() { - safe_clean ~/Library/Saved\ Application\ State/* "Saved application states" - - # REMOVED: Spotlight cache cleanup can cause system UI issues - # Spotlight indexes should be managed by macOS automatically - # safe_clean ~/Library/Caches/com.apple.spotlight "Spotlight cache" - - safe_clean ~/Library/Caches/com.apple.photoanalysisd "Photo analysis cache" - safe_clean ~/Library/Caches/com.apple.akd "Apple ID cache" - safe_clean ~/Library/Caches/com.apple.Safari/Webpage\ Previews/* "Safari webpage previews" - safe_clean ~/Library/Application\ Support/CloudDocs/session/db/* "iCloud session cache" - safe_clean ~/Library/Caches/com.apple.Safari/fsCachedData/* "Safari cached data" - safe_clean ~/Library/Caches/com.apple.WebKit.WebContent/* "WebKit content cache" - safe_clean ~/Library/Caches/com.apple.WebKit.Networking/* "WebKit network cache" -} - # Clean sandboxed app caches clean_sandboxed_app_caches() { safe_clean ~/Library/Containers/com.apple.wallpaper.agent/Data/Library/Caches/* "Wallpaper agent cache" @@ -115,44 +126,7 @@ clean_sandboxed_app_caches() { local found_any=false for container_dir in "$containers_dir"/*; do - [[ -d "$container_dir" ]] || continue - - # Extract bundle ID and check protection status early - local bundle_id=$(basename "$container_dir") - local bundle_id_lower=$(echo "$bundle_id" | tr '[:upper:]' '[:lower:]') - - # Check explicit critical system components (case-insensitive regex) - if [[ "$bundle_id_lower" =~ backgroundtaskmanagement || "$bundle_id_lower" =~ loginitems || "$bundle_id_lower" =~ systempreferences || "$bundle_id_lower" =~ systemsettings || "$bundle_id_lower" =~ settings || "$bundle_id_lower" =~ preferences || "$bundle_id_lower" =~ controlcenter || "$bundle_id_lower" =~ biometrickit || "$bundle_id_lower" =~ sfl || "$bundle_id_lower" =~ tcc ]]; then - continue - fi - - if should_protect_data "$bundle_id"; then - continue - elif should_protect_data "$bundle_id_lower"; then - continue - fi - - local cache_dir="$container_dir/Data/Library/Caches" - # Check if dir exists and has content - if [[ -d "$cache_dir" ]]; then - # Fast check if empty (avoid expensive size calc on empty dirs) - if [[ -n "$(ls -A "$cache_dir" 2> /dev/null)" ]]; then - # Get size - local size=$(get_path_size_kb "$cache_dir") - ((total_size += size)) - found_any=true - ((cleaned_count++)) - - if [[ "$DRY_RUN" != "true" ]]; then - # Clean contents safely - # We know this is a user cache path, so rm -rf is acceptable here - # provided we keep the Cache directory itself - for item in "${cache_dir:?}"/*; do - safe_remove "$item" true || true - done - fi - fi - fi + process_container_cache "$container_dir" done if [[ -t 1 ]]; then stop_inline_spinner; fi @@ -172,6 +146,46 @@ clean_sandboxed_app_caches() { fi } +# Process a single container cache directory (reduces nesting) +process_container_cache() { + local container_dir="$1" + [[ -d "$container_dir" ]] || return 0 + + # Extract bundle ID and check protection status early + local bundle_id=$(basename "$container_dir") + local bundle_id_lower=$(echo "$bundle_id" | tr '[:upper:]' '[:lower:]') + + # Check explicit critical system components (case-insensitive regex) + if [[ "$bundle_id_lower" =~ backgroundtaskmanagement || "$bundle_id_lower" =~ loginitems || "$bundle_id_lower" =~ systempreferences || "$bundle_id_lower" =~ systemsettings || "$bundle_id_lower" =~ settings || "$bundle_id_lower" =~ preferences || "$bundle_id_lower" =~ controlcenter || "$bundle_id_lower" =~ biometrickit || "$bundle_id_lower" =~ sfl || "$bundle_id_lower" =~ tcc ]]; then + return 0 + fi + + if should_protect_data "$bundle_id" || should_protect_data "$bundle_id_lower"; then + return 0 + fi + + local cache_dir="$container_dir/Data/Library/Caches" + # Check if dir exists and has content + [[ -d "$cache_dir" ]] || return 0 + + # Fast check if empty using find (more efficient than ls) + if find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then + # Use global variables from caller for tracking + local size=$(get_path_size_kb "$cache_dir") + ((total_size += size)) + found_any=true + ((cleaned_count++)) + + if [[ "$DRY_RUN" != "true" ]]; then + # Clean contents safely (rm -rf is restricted by safe_remove) + for item in "$cache_dir"/*; do + [[ -e "$item" ]] || continue + safe_remove "$item" true || true + done + fi + fi +} + # Clean browser caches (Safari, Chrome, Edge, Firefox, etc.) clean_browsers() { safe_clean ~/Library/Caches/com.apple.Safari/* "Safari cache" @@ -193,9 +207,6 @@ clean_browsers() { safe_clean ~/Library/Caches/com.kagi.kagimacOS/* "Orion cache" safe_clean ~/Library/Caches/zen/* "Zen cache" safe_clean ~/Library/Application\ Support/Firefox/Profiles/*/cache2/* "Firefox profile cache" - - # DISABLED: Service Worker CacheStorage scanning (find can hang on large browser profiles) - # Browser caches are already cleaned by the safe_clean calls above } # Clean cloud storage app caches @@ -271,14 +282,17 @@ clean_application_support_logs() { for candidate in "${start_candidates[@]}"; do if [[ -d "$candidate" ]]; then - if [[ -n "$(ls -A "$candidate" 2> /dev/null)" ]]; then + if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then local size=$(get_path_size_kb "$candidate") ((total_size += size)) ((cleaned_count++)) found_any=true if [[ "$DRY_RUN" != "true" ]]; then - safe_remove "$candidate"/* true > /dev/null 2>&1 || true + for item in "$candidate"/*; do + [[ -e "$item" ]] || continue + safe_remove "$item" true > /dev/null 2>&1 || true + done fi fi fi @@ -296,14 +310,17 @@ clean_application_support_logs() { for candidate in "${gc_candidates[@]}"; do if [[ -d "$candidate" ]]; then - if [[ -n "$(ls -A "$candidate" 2> /dev/null)" ]]; then + if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then local size=$(get_path_size_kb "$candidate") ((total_size += size)) ((cleaned_count++)) found_any=true if [[ "$DRY_RUN" != "true" ]]; then - safe_remove "$candidate"/* true > /dev/null 2>&1 || true + for item in "$candidate"/*; do + [[ -e "$item" ]] || continue + safe_remove "$item" true > /dev/null 2>&1 || true + done fi fi fi diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 29e6926..74b0898 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -533,12 +533,23 @@ should_protect_path() { # 4. Check for specific hardcoded critical patterns case "$path" in - *com.apple.Settings* | *com.apple.SystemSettings* | *com.apple.controlcenter* | *com.apple.finder*) + *com.apple.Settings* | *com.apple.SystemSettings* | *com.apple.controlcenter* | *com.apple.finder* | *com.apple.dock*) return 0 ;; esac - # 5. Check the full path against protected patterns (Broad Glob Match) + # 5. Protect critical preference files + case "$path" in + */Library/Preferences/com.apple.dock.plist | */Library/Preferences/com.apple.finder.plist) + return 0 + ;; + # Bluetooth and WiFi configurations + */ByHost/com.apple.bluetooth.* | */ByHost/com.apple.wifi.*) + return 0 + ;; + esac + + # 6. Check the full path against protected patterns (Broad Glob Match) # This catches things like /Users/tw93/Library/Caches/Claude when pattern is *Claude* for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do if bundle_matches_pattern "$path" "$pattern"; then @@ -546,7 +557,7 @@ should_protect_path() { fi done - # 6. Check if the filename itself matches any protected patterns + # 7. Check if the filename itself matches any protected patterns local filename filename=$(basename "$path") if should_protect_data "$filename"; then @@ -562,203 +573,124 @@ find_app_files() { local app_name="$2" local -a files_to_clean=() - # ============================================================================ - # User-level files (no sudo required) - # ============================================================================ + # Sanitized App Name (remove spaces) + local nospace_name="${app_name// /}" + local underscore_name="${app_name// /_}" - # Application Support - [[ -d ~/Library/Application\ Support/"$app_name" ]] && files_to_clean+=("$HOME/Library/Application Support/$app_name") - [[ -d ~/Library/Application\ Support/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Application Support/$bundle_id") + # Standard path patterns for user-level files + local -a user_patterns=( + "$HOME/Library/Application Support/$app_name" + "$HOME/Library/Application Support/$bundle_id" + "$HOME/Library/Caches/$bundle_id" + "$HOME/Library/Caches/$app_name" + "$HOME/Library/Logs/$app_name" + "$HOME/Library/Logs/$bundle_id" + "$HOME/Library/Application Support/CrashReporter/$app_name" + "$HOME/Library/Saved Application State/$bundle_id.savedState" + "$HOME/Library/Containers/$bundle_id" + "$HOME/Library/WebKit/$bundle_id" + "$HOME/Library/WebKit/com.apple.WebKit.WebContent/$bundle_id" + "$HOME/Library/HTTPStorages/$bundle_id" + "$HOME/Library/Cookies/$bundle_id.binarycookies" + "$HOME/Library/LaunchAgents/$bundle_id.plist" + "$HOME/Library/Application Scripts/$bundle_id" + "$HOME/Library/Services/$app_name.workflow" + "$HOME/Library/QuickLook/$app_name.qlgenerator" + "$HOME/Library/Internet Plug-Ins/$app_name.plugin" + "$HOME/Library/Audio/Plug-Ins/Components/$app_name.component" + "$HOME/Library/Audio/Plug-Ins/VST/$app_name.vst" + "$HOME/Library/Audio/Plug-Ins/VST3/$app_name.vst3" + "$HOME/Library/Audio/Plug-Ins/Digidesign/$app_name.dpm" + "$HOME/Library/PreferencePanes/$app_name.prefPane" + "$HOME/Library/Screen Savers/$app_name.saver" + "$HOME/Library/Frameworks/$app_name.framework" + "$HOME/Library/Autosave Information/$bundle_id" + "$HOME/Library/Contextual Menu Items/$app_name.plugin" + "$HOME/Library/Spotlight/$app_name.mdimporter" + "$HOME/Library/ColorPickers/$app_name.colorPicker" + "$HOME/Library/Workflows/$app_name.workflow" + "$HOME/.config/$app_name" + "$HOME/.local/share/$app_name" + "$HOME/.$app_name" + "$HOME/.$app_name"rc + ) - # Sanitized App Name (remove spaces) - e.g. "Visual Studio Code" -> "VisualStudioCode" + # Add sanitized name variants if unique enough if [[ ${#app_name} -gt 3 && "$app_name" =~ [[:space:]] ]]; then - local nospace_name="${app_name// /}" - [[ -d ~/Library/Application\ Support/"$nospace_name" ]] && files_to_clean+=("$HOME/Library/Application Support/$nospace_name") - [[ -d ~/Library/Caches/"$nospace_name" ]] && files_to_clean+=("$HOME/Library/Caches/$nospace_name") - [[ -d ~/Library/Logs/"$nospace_name" ]] && files_to_clean+=("$HOME/Library/Logs/$nospace_name") - - local underscore_name="${app_name// /_}" - [[ -d ~/Library/Application\ Support/"$underscore_name" ]] && files_to_clean+=("$HOME/Library/Application Support/$underscore_name") + user_patterns+=( + "$HOME/Library/Application Support/$nospace_name" + "$HOME/Library/Caches/$nospace_name" + "$HOME/Library/Logs/$nospace_name" + "$HOME/Library/Application Support/$underscore_name" + ) fi - # Caches - [[ -d ~/Library/Caches/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Caches/$bundle_id") - [[ -d ~/Library/Caches/"$app_name" ]] && files_to_clean+=("$HOME/Library/Caches/$app_name") + # Process standard patterns + for p in "${user_patterns[@]}"; do + local expanded_path="${p/#\~/$HOME}" + [[ -e "$expanded_path" ]] && files_to_clean+=("$expanded_path") + done - # Preferences + # Preferences and ByHost (special handling) [[ -f ~/Library/Preferences/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id.plist") [[ -d ~/Library/Preferences/ByHost ]] && while IFS= read -r -d '' pref; do files_to_clean+=("$pref") - done < <(find ~/Library/Preferences/ByHost \( -name "$bundle_id*.plist" \) -print0 2> /dev/null) + done < <(command find ~/Library/Preferences/ByHost -maxdepth 1 \( -name "$bundle_id*.plist" \) -print0 2> /dev/null) - # Logs - [[ -d ~/Library/Logs/"$app_name" ]] && files_to_clean+=("$HOME/Library/Logs/$app_name") - [[ -d ~/Library/Logs/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Logs/$bundle_id") - # CrashReporter - [[ -d ~/Library/Application\ Support/CrashReporter/"$app_name" ]] && files_to_clean+=("$HOME/Library/Application Support/CrashReporter/$app_name") + # Group Containers (special handling) + if [[ -d ~/Library/Group\ Containers ]]; then + while IFS= read -r -d '' container; do + files_to_clean+=("$container") + done < <(command find ~/Library/Group\ Containers -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null) + fi - # Saved Application State - [[ -d ~/Library/Saved\ Application\ State/"$bundle_id".savedState ]] && files_to_clean+=("$HOME/Library/Saved Application State/$bundle_id.savedState") - - # Containers (sandboxed apps) - [[ -d ~/Library/Containers/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Containers/$bundle_id") - - # Group Containers - [[ -d ~/Library/Group\ Containers ]] && while IFS= read -r -d '' container; do - files_to_clean+=("$container") - done < <(find ~/Library/Group\ Containers -type d \( -name "*$bundle_id*" \) -print0 2> /dev/null) - - # WebKit data - [[ -d ~/Library/WebKit/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/WebKit/$bundle_id") - [[ -d ~/Library/WebKit/com.apple.WebKit.WebContent/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/WebKit/com.apple.WebKit.WebContent/$bundle_id") - - # HTTP Storage - [[ -d ~/Library/HTTPStorages/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/HTTPStorages/$bundle_id") - - # Cookies - [[ -f ~/Library/Cookies/"$bundle_id".binarycookies ]] && files_to_clean+=("$HOME/Library/Cookies/$bundle_id.binarycookies") - - # Launch Agents (user-level) - [[ -f ~/Library/LaunchAgents/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/LaunchAgents/$bundle_id.plist") - # Search for LaunchAgents by app name if unique enough - if [[ ${#app_name} -gt 3 ]]; then + # Launch Agents by name (special handling) + if [[ ${#app_name} -gt 3 ]] && [[ -d ~/Library/LaunchAgents ]]; then while IFS= read -r -d '' plist; do files_to_clean+=("$plist") - done < <(find ~/Library/LaunchAgents -name "*$app_name*.plist" -print0 2> /dev/null) + done < <(command find ~/Library/LaunchAgents -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null) fi - # Application Scripts - [[ -d ~/Library/Application\ Scripts/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Application Scripts/$bundle_id") - - # Services - [[ -d ~/Library/Services/"$app_name".workflow ]] && files_to_clean+=("$HOME/Library/Services/$app_name.workflow") - - # QuickLook Plugins - [[ -d ~/Library/QuickLook/"$app_name".qlgenerator ]] && files_to_clean+=("$HOME/Library/QuickLook/$app_name.qlgenerator") - - # Internet Plug-Ins - [[ -d ~/Library/Internet\ Plug-Ins/"$app_name".plugin ]] && files_to_clean+=("$HOME/Library/Internet Plug-Ins/$app_name.plugin") - - # Audio Plug-Ins (Components, VST, VST3) - [[ -d ~/Library/Audio/Plug-Ins/Components/"$app_name".component ]] && files_to_clean+=("$HOME/Library/Audio/Plug-Ins/Components/$app_name.component") - [[ -d ~/Library/Audio/Plug-Ins/VST/"$app_name".vst ]] && files_to_clean+=("$HOME/Library/Audio/Plug-Ins/VST/$app_name.vst") - [[ -d ~/Library/Audio/Plug-Ins/VST3/"$app_name".vst3 ]] && files_to_clean+=("$HOME/Library/Audio/Plug-Ins/VST3/$app_name.vst3") - [[ -d ~/Library/Audio/Plug-Ins/Digidesign/"$app_name".dpm ]] && files_to_clean+=("$HOME/Library/Audio/Plug-Ins/Digidesign/$app_name.dpm") - - # Preference Panes - [[ -d ~/Library/PreferencePanes/"$app_name".prefPane ]] && files_to_clean+=("$HOME/Library/PreferencePanes/$app_name.prefPane") - - # Screen Savers - [[ -d ~/Library/Screen\ Savers/"$app_name".saver ]] && files_to_clean+=("$HOME/Library/Screen Savers/$app_name.saver") - - # Frameworks - [[ -d ~/Library/Frameworks/"$app_name".framework ]] && files_to_clean+=("$HOME/Library/Frameworks/$app_name.framework") - - # Autosave Information - [[ -d ~/Library/Autosave\ Information/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Autosave Information/$bundle_id") - - # Contextual Menu Items - [[ -d ~/Library/Contextual\ Menu\ Items/"$app_name".plugin ]] && files_to_clean+=("$HOME/Library/Contextual Menu Items/$app_name.plugin") - - # Spotlight Plugins - [[ -d ~/Library/Spotlight/"$app_name".mdimporter ]] && files_to_clean+=("$HOME/Library/Spotlight/$app_name.mdimporter") - - # Color Pickers - [[ -d ~/Library/ColorPickers/"$app_name".colorPicker ]] && files_to_clean+=("$HOME/Library/ColorPickers/$app_name.colorPicker") - - # Workflows - [[ -d ~/Library/Workflows/"$app_name".workflow ]] && files_to_clean+=("$HOME/Library/Workflows/$app_name.workflow") - - # Unix-style configuration directories and files (cross-platform apps) - [[ -d ~/.config/"$app_name" ]] && files_to_clean+=("$HOME/.config/$app_name") - [[ -d ~/.local/share/"$app_name" ]] && files_to_clean+=("$HOME/.local/share/$app_name") - [[ -d ~/."$app_name" ]] && files_to_clean+=("$HOME/.$app_name") - [[ -f ~/."${app_name}rc" ]] && files_to_clean+=("$HOME/.${app_name}rc") - - # ============================================================================ - # IDE-specific SDK and Toolchain directories - # ============================================================================ - - # DevEco-Studio (HarmonyOS/OpenHarmony IDE by Huawei) + # Specialized toolchain cleanup (non-loopable or highly specific) + # 1. DevEco-Studio (Huawei) if [[ "$app_name" =~ DevEco|deveco ]] || [[ "$bundle_id" =~ huawei.*deveco ]]; then - [[ -d ~/DevEcoStudioProjects ]] && files_to_clean+=("$HOME/DevEcoStudioProjects") - [[ -d ~/DevEco-Studio ]] && files_to_clean+=("$HOME/DevEco-Studio") - [[ -d ~/Library/Application\ Support/Huawei ]] && files_to_clean+=("$HOME/Library/Application Support/Huawei") - [[ -d ~/Library/Caches/Huawei ]] && files_to_clean+=("$HOME/Library/Caches/Huawei") - [[ -d ~/Library/Logs/Huawei ]] && files_to_clean+=("$HOME/Library/Logs/Huawei") - [[ -d ~/Library/Huawei ]] && files_to_clean+=("$HOME/Library/Huawei") - [[ -d ~/Huawei ]] && files_to_clean+=("$HOME/Huawei") - [[ -d ~/HarmonyOS ]] && files_to_clean+=("$HOME/HarmonyOS") - [[ -d ~/.huawei ]] && files_to_clean+=("$HOME/.huawei") - [[ -d ~/.ohos ]] && files_to_clean+=("$HOME/.ohos") + for d in ~/DevEcoStudioProjects ~/DevEco-Studio ~/Library/Application\ Support/Huawei ~/Library/Caches/Huawei ~/Library/Logs/Huawei ~/Library/Huawei ~/Huawei ~/HarmonyOS ~/.huawei ~/.ohos; do + [[ -d "$d" ]] && files_to_clean+=("$d") + done fi - # Android Studio + # 2. Android Studio (Google) if [[ "$app_name" =~ Android.*Studio|android.*studio ]] || [[ "$bundle_id" =~ google.*android.*studio|jetbrains.*android ]]; then - [[ -d ~/AndroidStudioProjects ]] && files_to_clean+=("$HOME/AndroidStudioProjects") - [[ -d ~/Library/Android ]] && files_to_clean+=("$HOME/Library/Android") - [[ -d ~/.android ]] && files_to_clean+=("$HOME/.android") - [[ -d ~/.gradle ]] && files_to_clean+=("$HOME/.gradle") - [[ -d ~/Library/Application\ Support/Google ]] && - while IFS= read -r -d '' dir; do files_to_clean+=("$dir"); done < <(find ~/Library/Application\ Support/Google -maxdepth 1 -name "AndroidStudio*" -print0 2> /dev/null) + for d in ~/AndroidStudioProjects ~/Library/Android ~/.android ~/.gradle; do + [[ -d "$d" ]] && files_to_clean+=("$d") + done + [[ -d ~/Library/Application\ Support/Google ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find ~/Library/Application\ Support/Google -maxdepth 1 -name "AndroidStudio*" -print0 2> /dev/null) fi - # Xcode + # 3. Xcode (Apple) if [[ "$app_name" =~ Xcode|xcode ]] || [[ "$bundle_id" =~ apple.*xcode ]]; then [[ -d ~/Library/Developer ]] && files_to_clean+=("$HOME/Library/Developer") [[ -d ~/.Xcode ]] && files_to_clean+=("$HOME/.Xcode") fi - # IntelliJ IDEA, PyCharm, WebStorm, etc. (JetBrains IDEs) + # 4. JetBrains (IDE settings) if [[ "$bundle_id" =~ jetbrains ]] || [[ "$app_name" =~ IntelliJ|PyCharm|WebStorm|GoLand|RubyMine|PhpStorm|CLion|DataGrip|Rider ]]; then - local ide_name="$app_name" - [[ -d ~/Library/Application\ Support/JetBrains ]] && - while IFS= read -r -d '' dir; do files_to_clean+=("$dir"); done < <(find ~/Library/Application\ Support/JetBrains -maxdepth 1 -name "${ide_name}*" -print0 2> /dev/null) - [[ -d ~/Library/Caches/JetBrains ]] && - while IFS= read -r -d '' dir; do files_to_clean+=("$dir"); done < <(find ~/Library/Caches/JetBrains -maxdepth 1 -name "${ide_name}*" -print0 2> /dev/null) - [[ -d ~/Library/Logs/JetBrains ]] && - while IFS= read -r -d '' dir; do files_to_clean+=("$dir"); done < <(find ~/Library/Logs/JetBrains -maxdepth 1 -name "${ide_name}*" -print0 2> /dev/null) + for base in ~/Library/Application\ Support/JetBrains ~/Library/Caches/JetBrains ~/Library/Logs/JetBrains; do + [[ -d "$base" ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find "$base" -maxdepth 1 -name "${app_name}*" -print0 2> /dev/null) + done fi - # Unity - if [[ "$app_name" =~ Unity|unity ]] || [[ "$bundle_id" =~ unity ]]; then - [[ -d ~/.local/share/unity3d ]] && files_to_clean+=("$HOME/.local/share/unity3d") - [[ -d ~/Library/Unity ]] && files_to_clean+=("$HOME/Library/Unity") - fi + # 5. Unity / Unreal / Godot + [[ "$app_name" =~ Unity|unity ]] && [[ -d ~/Library/Unity ]] && files_to_clean+=("$HOME/Library/Unity") + [[ "$app_name" =~ Unreal|unreal ]] && [[ -d ~/Library/Application\ Support/Epic ]] && files_to_clean+=("$HOME/Library/Application Support/Epic") + [[ "$app_name" =~ Godot|godot ]] && [[ -d ~/Library/Application\ Support/Godot ]] && files_to_clean+=("$HOME/Library/Application Support/Godot") - # Unreal Engine - if [[ "$app_name" =~ Unreal|unreal ]] || [[ "$bundle_id" =~ unrealengine|epicgames ]]; then - [[ -d ~/Library/Application\ Support/Epic ]] && files_to_clean+=("$HOME/Library/Application Support/Epic") - [[ -d ~/Documents/Unreal\ Projects ]] && files_to_clean+=("$HOME/Documents/Unreal Projects") - fi + # 6. Tools + [[ "$bundle_id" =~ microsoft.*vscode ]] && [[ -d ~/.vscode ]] && files_to_clean+=("$HOME/.vscode") + [[ "$app_name" =~ Docker ]] && [[ -d ~/.docker ]] && files_to_clean+=("$HOME/.docker") - # Visual Studio Code - if [[ "$bundle_id" =~ microsoft.*vscode|visualstudio.*code ]]; then - [[ -d ~/.vscode ]] && files_to_clean+=("$HOME/.vscode") - [[ -d ~/.vscode-insiders ]] && files_to_clean+=("$HOME/.vscode-insiders") - fi - - # Flutter - if [[ "$app_name" =~ Flutter|flutter ]] || [[ "$bundle_id" =~ flutter ]]; then - [[ -d ~/.pub-cache ]] && files_to_clean+=("$HOME/.pub-cache") - [[ -d ~/flutter ]] && files_to_clean+=("$HOME/flutter") - fi - - # Godot - if [[ "$app_name" =~ Godot|godot ]] || [[ "$bundle_id" =~ godot ]]; then - [[ -d ~/.local/share/godot ]] && files_to_clean+=("$HOME/.local/share/godot") - [[ -d ~/Library/Application\ Support/Godot ]] && files_to_clean+=("$HOME/Library/Application Support/Godot") - fi - - # Docker Desktop - if [[ "$app_name" =~ Docker ]] || [[ "$bundle_id" =~ docker ]]; then - [[ -d ~/.docker ]] && files_to_clean+=("$HOME/.docker") - fi - - # Only print if array has elements to avoid unbound variable error - if [[ ${#files_to_clean[@]} -gt 0 ]]; then - printf '%s\n' "${files_to_clean[@]}" - fi + # Output results + [[ ${#files_to_clean[@]} -gt 0 ]] && printf '%s\n' "${files_to_clean[@]}" } # Find system-level app files (requires sudo) @@ -767,82 +699,63 @@ find_app_system_files() { local app_name="$2" local -a system_files=() - # System Application Support - [[ -d /Library/Application\ Support/"$app_name" ]] && system_files+=("/Library/Application Support/$app_name") - [[ -d /Library/Application\ Support/"$bundle_id" ]] && system_files+=("/Library/Application Support/$bundle_id") - # Sanitized App Name (remove spaces) + local nospace_name="${app_name// /}" + + # Standard system path patterns + local -a system_patterns=( + "/Library/Application Support/$app_name" + "/Library/Application Support/$bundle_id" + "/Library/LaunchAgents/$bundle_id.plist" + "/Library/LaunchDaemons/$bundle_id.plist" + "/Library/Preferences/$bundle_id.plist" + "/Library/Receipts/$bundle_id.bom" + "/Library/Receipts/$bundle_id.plist" + "/Library/Frameworks/$app_name.framework" + "/Library/Internet Plug-Ins/$app_name.plugin" + "/Library/Audio/Plug-Ins/Components/$app_name.component" + "/Library/Audio/Plug-Ins/VST/$app_name.vst" + "/Library/Audio/Plug-Ins/VST3/$app_name.vst3" + "/Library/Audio/Plug-Ins/Digidesign/$app_name.dpm" + "/Library/QuickLook/$app_name.qlgenerator" + "/Library/PreferencePanes/$app_name.prefPane" + "/Library/Screen Savers/$app_name.saver" + "/Library/Caches/$bundle_id" + "/Library/Caches/$app_name" + ) + if [[ ${#app_name} -gt 3 && "$app_name" =~ [[:space:]] ]]; then - local nospace_name="${app_name// /}" - [[ -d /Library/Application\ Support/"$nospace_name" ]] && system_files+=("/Library/Application Support/$nospace_name") - [[ -d /Library/Caches/"$nospace_name" ]] && system_files+=("/Library/Caches/$nospace_name") - [[ -d /Library/Logs/"$nospace_name" ]] && system_files+=("/Library/Logs/$nospace_name") + system_patterns+=( + "/Library/Application Support/$nospace_name" + "/Library/Caches/$nospace_name" + "/Library/Logs/$nospace_name" + ) fi - # System Launch Agents - [[ -f /Library/LaunchAgents/"$bundle_id".plist ]] && system_files+=("/Library/LaunchAgents/$bundle_id.plist") - # Search for LaunchAgents by app name if unique enough + # Process patterns + for p in "${system_patterns[@]}"; do + [[ -e "$p" ]] && system_files+=("$p") + done + + # System LaunchAgents/LaunchDaemons by name if [[ ${#app_name} -gt 3 ]]; then - while IFS= read -r -d '' plist; do - system_files+=("$plist") - done < <(find /Library/LaunchAgents -name "*$app_name*.plist" -print0 2> /dev/null) + for base in /Library/LaunchAgents /Library/LaunchDaemons; do + [[ -d "$base" ]] && while IFS= read -r -d '' plist; do + system_files+=("$plist") + done < <(command find "$base" -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null) + done fi - # System Launch Daemons - [[ -f /Library/LaunchDaemons/"$bundle_id".plist ]] && system_files+=("/Library/LaunchDaemons/$bundle_id.plist") - # Search for LaunchDaemons by app name if unique enough - if [[ ${#app_name} -gt 3 ]]; then - while IFS= read -r -d '' plist; do - system_files+=("$plist") - done < <(find /Library/LaunchDaemons -name "*$app_name*.plist" -print0 2> /dev/null) - fi - - # Privileged Helper Tools + # Privileged Helper Tools and Receipts (special handling) [[ -d /Library/PrivilegedHelperTools ]] && while IFS= read -r -d '' helper; do system_files+=("$helper") - done < <(find /Library/PrivilegedHelperTools \( -name "$bundle_id*" \) -print0 2> /dev/null) + done < <(command find /Library/PrivilegedHelperTools -maxdepth 1 \( -name "$bundle_id*" \) -print0 2> /dev/null) - # System Preferences - [[ -f /Library/Preferences/"$bundle_id".plist ]] && system_files+=("/Library/Preferences/$bundle_id.plist") - - # Installation Receipts [[ -d /private/var/db/receipts ]] && while IFS= read -r -d '' receipt; do system_files+=("$receipt") - done < <(find /private/var/db/receipts \( -name "*$bundle_id*" \) -print0 2> /dev/null) + done < <(command find /private/var/db/receipts -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null) - # System Logs - [[ -d /Library/Logs/"$app_name" ]] && system_files+=("/Library/Logs/$app_name") - [[ -d /Library/Logs/"$bundle_id" ]] && system_files+=("/Library/Logs/$bundle_id") - - # System Frameworks - [[ -d /Library/Frameworks/"$app_name".framework ]] && system_files+=("/Library/Frameworks/$app_name.framework") - - # System Internet Plug-Ins - [[ -d /Library/Internet\ Plug-Ins/"$app_name".plugin ]] && system_files+=("/Library/Internet Plug-Ins/$app_name.plugin") - - # System Audio Plug-Ins - [[ -d /Library/Audio/Plug-Ins/Components/"$app_name".component ]] && system_files+=("/Library/Audio/Plug-Ins/Components/$app_name.component") - [[ -d /Library/Audio/Plug-Ins/VST/"$app_name".vst ]] && system_files+=("/Library/Audio/Plug-Ins/VST/$app_name.vst") - [[ -d /Library/Audio/Plug-Ins/VST3/"$app_name".vst3 ]] && system_files+=("/Library/Audio/Plug-Ins/VST3/$app_name.vst3") - [[ -d /Library/Audio/Plug-Ins/Digidesign/"$app_name".dpm ]] && system_files+=("/Library/Audio/Plug-Ins/Digidesign/$app_name.dpm") - - # System QuickLook Plugins - [[ -d /Library/QuickLook/"$app_name".qlgenerator ]] && system_files+=("/Library/QuickLook/$app_name.qlgenerator") - - # System Preference Panes - [[ -d /Library/PreferencePanes/"$app_name".prefPane ]] && system_files+=("/Library/PreferencePanes/$app_name.prefPane") - - # System Screen Savers - [[ -d /Library/Screen\ Savers/"$app_name".saver ]] && system_files+=("/Library/Screen Savers/$app_name.saver") - - # System Caches - [[ -d /Library/Caches/"$bundle_id" ]] && system_files+=("/Library/Caches/$bundle_id") - [[ -d /Library/Caches/"$app_name" ]] && system_files+=("/Library/Caches/$app_name") - - # Only print if array has elements - if [[ ${#system_files[@]} -gt 0 ]]; then - printf '%s\n' "${system_files[@]}" - fi + [[ ${#system_files[@]} -gt 0 ]] && printf '%s\n' "${system_files[@]}" # Find files from receipts (Deep Scan) find_app_receipt_files "$bundle_id" @@ -863,7 +776,7 @@ find_app_receipt_files() { if [[ -d /private/var/db/receipts ]]; then while IFS= read -r -d '' bom; do bom_files+=("$bom") - done < <(find /private/var/db/receipts -name "${bundle_id}*.bom" -print0 2> /dev/null) + done < <(find /private/var/db/receipts -maxdepth 1 -name "${bundle_id}*.bom" -print0 2> /dev/null) fi # Process bom files if any found diff --git a/lib/core/base.sh b/lib/core/base.sh index 4555732..5ad3edf 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -48,6 +48,7 @@ readonly MOLE_TEMP_FILE_AGE_DAYS=7 # Temp file cleanup threshold readonly MOLE_ORPHAN_AGE_DAYS=60 # Orphaned data threshold readonly MOLE_MAX_PARALLEL_JOBS=15 # Parallel job limit readonly MOLE_MAIL_DOWNLOADS_MIN_KB=5120 # Mail attachments size threshold +readonly MOLE_MAIL_AGE_DAYS=30 # Mail attachment cleanup threshold (30+ days) readonly MOLE_LOG_AGE_DAYS=7 # System log retention readonly MOLE_CRASH_REPORT_AGE_DAYS=7 # Crash report retention readonly MOLE_SAVED_STATE_AGE_DAYS=7 # App saved state retention diff --git a/lib/core/common.sh b/lib/core/common.sh index 38beb4c..b7bb7bc 100755 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -71,7 +71,7 @@ update_via_homebrew() { echo "" fi - # Clear update cache + # Clear update cache (suppress errors if cache doesn't exist or is locked) rm -f "$HOME/.cache/mole/version_check" "$HOME/.cache/mole/update_message" 2> /dev/null || true } diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index fe49a3c..4446d7b 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -188,7 +188,7 @@ safe_find_delete() { -maxdepth 5 \ -name "$pattern" \ -type "$type_filter" \ - -delete 2> /dev/null || true + -delete 2> /dev/null || true # Suppress expected errors when files are removed or protected by other processes else # Delete files older than age_days command find "$base_dir" \ @@ -196,7 +196,7 @@ safe_find_delete() { -name "$pattern" \ -type "$type_filter" \ -mtime "+$age_days" \ - -delete 2> /dev/null || true + -delete 2> /dev/null || true # Suppress expected errors when files are removed or protected by other processes fi return 0 @@ -237,14 +237,14 @@ safe_sudo_find_delete() { -maxdepth 5 \ -name "$pattern" \ -type "$type_filter" \ - -delete 2> /dev/null || true + -delete 2> /dev/null || true # Ignore transient errors for system files that might be in use or protected else sudo find "$base_dir" \ -maxdepth 5 \ -name "$pattern" \ -type "$type_filter" \ -mtime "+$age_days" \ - -delete 2> /dev/null || true + -delete 2> /dev/null || true # Ignore transient errors for system files that might be in use or protected fi return 0 diff --git a/lib/optimize/tasks.sh b/lib/optimize/tasks.sh index 9a30fd9..4ca47f9 100644 --- a/lib/optimize/tasks.sh +++ b/lib/optimize/tasks.sh @@ -3,6 +3,19 @@ set -euo pipefail +# Configuration constants +# MOLE_TM_THIN_TIMEOUT: Max seconds to wait for tmutil thinning (default: 180) +# MOLE_TM_THIN_VALUE: Bytes to thin for local snapshots (default: 9999999999) +# MOLE_MAIL_DOWNLOADS_MIN_KB: Minimum size in KB before cleaning Mail attachments (default: 5120) +# MOLE_MAIL_AGE_DAYS: Minimum age in days for Mail attachments to be cleaned (default: 30) +readonly MOLE_TM_THIN_TIMEOUT=180 +readonly MOLE_TM_THIN_VALUE=9999999999 + +# Helper function: Flush DNS cache +flush_dns_cache() { + sudo dscacheutil -flushcache 2> /dev/null && sudo killall -HUP mDNSResponder 2> /dev/null +} + # System maintenance: rebuild databases and flush caches opt_system_maintenance() { echo -e "${BLUE}${ICON_ARROW}${NC} Rebuilding LaunchServices database..." @@ -10,25 +23,21 @@ opt_system_maintenance() { echo -e "${GREEN}${ICON_SUCCESS}${NC} LaunchServices database rebuilt" echo -e "${BLUE}${ICON_ARROW}${NC} Clearing DNS cache..." - if sudo dscacheutil -flushcache 2> /dev/null && sudo killall -HUP mDNSResponder 2> /dev/null; then + if flush_dns_cache; then echo -e "${GREEN}${ICON_SUCCESS}${NC} DNS cache cleared" else echo -e "${RED}${ICON_ERROR}${NC} Failed to clear DNS cache" fi echo -e "${BLUE}${ICON_ARROW}${NC} Checking Spotlight index..." - local md_status - md_status=$(mdutil -s / 2> /dev/null || echo "") - if echo "$md_status" | grep -qi "Indexing disabled"; then + local spotlight_status + spotlight_status=$(mdutil -s / 2> /dev/null || echo "") + if echo "$spotlight_status" | grep -qi "Indexing disabled"; then echo -e "${GRAY}-${NC} Spotlight indexing disabled" else echo -e "${GREEN}${ICON_SUCCESS}${NC} Spotlight index functioning" fi - echo -e "${BLUE}${ICON_ARROW}${NC} Refreshing Bluetooth services..." - sudo pkill -f blued 2> /dev/null || true - echo -e "${GREEN}${ICON_SUCCESS}${NC} Bluetooth controller refreshed" - } # Cache refresh: update Finder/Safari caches @@ -131,19 +140,47 @@ opt_radio_refresh() { # Mail downloads: clear OLD Mail attachment cache (30+ days) opt_mail_downloads() { - echo -e "${BLUE}${ICON_ARROW}${NC} Clearing old Mail attachment downloads (30+ days)..." + # Validate configuration parameters + # Validate configuration parameters + local min_size_kb=${MOLE_MAIL_DOWNLOADS_MIN_KB:-5120} + local mail_age_days=${MOLE_MAIL_AGE_DAYS:-30} + if ! [[ "$min_size_kb" =~ ^[0-9]+$ ]]; then + min_size_kb=5120 + fi + if ! [[ "$mail_age_days" =~ ^[0-9]+$ ]]; then + mail_age_days=30 + fi + + echo -e "${BLUE}${ICON_ARROW}${NC} Clearing old Mail attachment downloads (${mail_age_days}+ days)..." local -a mail_dirs=( "$HOME/Library/Mail Downloads" "$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads" ) - local total_kb=0 + local total_size_kb=0 + local temp_dir + temp_dir=$(create_temp_dir) + + # Parallel size calculation + local idx=0 for target_path in "${mail_dirs[@]}"; do - total_kb=$((total_kb + $(get_path_size_kb "$target_path"))) + ( + local size + size=$(get_path_size_kb "$target_path") + echo "$size" > "$temp_dir/size_$idx" + ) & + ((idx++)) + done + wait + + for i in $(seq 0 $((idx - 1))); do + local size=0 + [[ -f "$temp_dir/size_$i" ]] && size=$(cat "$temp_dir/size_$i") + ((total_size_kb += size)) done - if [[ $total_kb -lt $MOLE_MAIL_DOWNLOADS_MIN_KB ]]; then - echo -e "${GRAY}-${NC} Only $(bytes_to_human $((total_kb * 1024))) detected, skipping cleanup" + if [[ $total_size_kb -lt $min_size_kb ]]; then + echo -e "${GRAY}-${NC} Only $(bytes_to_human $((total_size_kb * 1024))) detected, skipping cleanup" return fi @@ -151,13 +188,13 @@ opt_mail_downloads() { local cleaned=false for target_path in "${mail_dirs[@]}"; do if [[ -d "$target_path" ]]; then - safe_find_delete "$target_path" "*" "$MOLE_LOG_AGE_DAYS" "f" + safe_find_delete "$target_path" "*" "$mail_age_days" "f" cleaned=true fi done if [[ "$cleaned" == "true" ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} Cleaned old attachments (> ${MOLE_LOG_AGE_DAYS} days)" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Cleaned old attachments (> ${mail_age_days} days)" else echo -e "${GRAY}-${NC} No old attachments found" fi @@ -230,7 +267,13 @@ opt_local_snapshots() { fi local success=false - if run_with_timeout 180 sudo tmutil thinlocalsnapshots / 9999999999 4 > /dev/null 2>&1; then + local exit_code=0 + set +e + run_with_timeout "$MOLE_TM_THIN_TIMEOUT" sudo tmutil thinlocalsnapshots / "$MOLE_TM_THIN_VALUE" 4 > /dev/null 2>&1 + exit_code=$? + set -e + + if [[ "$exit_code" -eq 0 ]]; then success=true fi @@ -244,8 +287,10 @@ opt_local_snapshots() { if [[ "$success" == "true" ]]; then echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed $removed snapshots (remaining: $after)" + elif [[ "$exit_code" -eq 124 ]]; then + echo -e "${YELLOW}!${NC} Timed out after ${MOLE_TM_THIN_TIMEOUT}s" else - echo -e "${YELLOW}!${NC} Timed out or failed" + echo -e "${YELLOW}!${NC} Failed with exit code $exit_code" fi } @@ -310,7 +355,7 @@ opt_network_optimization() { local steps=0 # 1. Flush DNS cache - if sudo dscacheutil -flushcache 2> /dev/null && sudo killall -HUP mDNSResponder 2> /dev/null; then + if flush_dns_cache; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} DNS cache flushed" ((steps++)) fi diff --git a/tests/purge.bats b/tests/purge.bats index c78ebe7..6283486 100644 --- a/tests/purge.bats +++ b/tests/purge.bats @@ -29,7 +29,7 @@ setup() { mkdir -p "$HOME/.cache/mole" # Clean any previous test artifacts - rm -rf "$HOME/www"/* "$HOME/dev"/* + rm -rf "${HOME:?}/www"/* "${HOME:?}/dev"/* } # ================================================================= @@ -151,7 +151,8 @@ setup() { source '$PROJECT_ROOT/lib/clean/project.sh' is_recently_modified '$HOME/www/old-project/node_modules' || true " - [ "$?" -eq 0 ] || [ "$?" -eq 1 ] # Allow both true/false, just check no crash + local exit_code=$? + [ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 1 ] # Allow both true/false, just check no crash } # ================================================================= diff --git a/tests/system_maintenance.bats b/tests/system_maintenance.bats index cca8e04..d3e7e36 100644 --- a/tests/system_maintenance.bats +++ b/tests/system_maintenance.bats @@ -97,7 +97,7 @@ clean_time_machine_failed_backups EOF [ "$status" -eq 0 ] - [[ "$output" == *"No failed Time Machine backups found"* ]] + [[ "$output" == *"No incomplete backups found"* ]] } diff --git a/tests/tmp-clean-home.EmChvN/.config/mole/clean-list.txt b/tests/tmp-clean-home.EmChvN/.config/mole/clean-list.txt new file mode 100644 index 0000000..e8a8f21 --- /dev/null +++ b/tests/tmp-clean-home.EmChvN/.config/mole/clean-list.txt @@ -0,0 +1,13 @@ +# Mole Cleanup Preview - 2025-12-18 17:01:44 +# +# 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 +# + + +=== User essentials === +/Users/tw93/www/Mole/tests/tmp-clean-home.EmChvN/Library/Caches/TestApp # 4KB