1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-08 14:24:21 +00:00

feat: Enhance clean, optimize, analyze, and status commands, and update security audit documentation.

This commit is contained in:
Tw93
2025-12-31 16:23:31 +08:00
parent 8ac59da0e2
commit 9aa569cbb6
53 changed files with 538 additions and 1659 deletions

View File

@@ -1,22 +1,19 @@
#!/bin/bash
# User GUI Applications Cleanup Module
# Desktop applications, communication tools, media players, games, utilities
# User GUI Applications Cleanup Module (desktop apps, media, utilities).
set -euo pipefail
# Clean Xcode and iOS development tools
# Xcode and iOS tooling.
clean_xcode_tools() {
# Check if Xcode is running for safer cleanup of critical resources
# Skip DerivedData/Archives while Xcode is running.
local xcode_running=false
if pgrep -x "Xcode" > /dev/null 2>&1; then
xcode_running=true
fi
# Safe to clean regardless of Xcode state
safe_clean ~/Library/Developer/CoreSimulator/Caches/* "Simulator cache"
safe_clean ~/Library/Developer/CoreSimulator/Devices/*/data/tmp/* "Simulator temp files"
safe_clean ~/Library/Caches/com.apple.dt.Xcode/* "Xcode cache"
safe_clean ~/Library/Developer/Xcode/iOS\ Device\ Logs/* "iOS device logs"
safe_clean ~/Library/Developer/Xcode/watchOS\ Device\ Logs/* "watchOS device logs"
safe_clean ~/Library/Developer/Xcode/Products/* "Xcode build products"
# Clean build artifacts only if Xcode is not running
if [[ "$xcode_running" == "false" ]]; then
safe_clean ~/Library/Developer/Xcode/DerivedData/* "Xcode derived data"
safe_clean ~/Library/Developer/Xcode/Archives/* "Xcode archives"
@@ -24,7 +21,7 @@ clean_xcode_tools() {
echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode is running, skipping DerivedData and Archives cleanup"
fi
}
# Clean code editors (VS Code, Sublime, etc.)
# Code editors.
clean_code_editors() {
safe_clean ~/Library/Application\ Support/Code/logs/* "VS Code logs"
safe_clean ~/Library/Application\ Support/Code/Cache/* "VS Code cache"
@@ -32,7 +29,7 @@ clean_code_editors() {
safe_clean ~/Library/Application\ Support/Code/CachedData/* "VS Code data cache"
safe_clean ~/Library/Caches/com.sublimetext.*/* "Sublime Text cache"
}
# Clean communication apps (Slack, Discord, Zoom, etc.)
# Communication apps.
clean_communication_apps() {
safe_clean ~/Library/Application\ Support/discord/Cache/* "Discord cache"
safe_clean ~/Library/Application\ Support/legcord/Cache/* "Legcord cache"
@@ -47,43 +44,43 @@ clean_communication_apps() {
safe_clean ~/Library/Caches/com.tencent.WeWorkMac/* "WeCom cache"
safe_clean ~/Library/Caches/com.feishu.*/* "Feishu cache"
}
# Clean DingTalk
# DingTalk.
clean_dingtalk() {
safe_clean ~/Library/Caches/dd.work.exclusive4aliding/* "DingTalk iDingTalk cache"
safe_clean ~/Library/Caches/com.alibaba.AliLang.osx/* "AliLang security component"
safe_clean ~/Library/Application\ Support/iDingTalk/log/* "DingTalk logs"
safe_clean ~/Library/Application\ Support/iDingTalk/holmeslogs/* "DingTalk holmes logs"
}
# Clean AI assistants
# AI assistants.
clean_ai_apps() {
safe_clean ~/Library/Caches/com.openai.chat/* "ChatGPT cache"
safe_clean ~/Library/Caches/com.anthropic.claudefordesktop/* "Claude desktop cache"
safe_clean ~/Library/Logs/Claude/* "Claude logs"
}
# Clean design and creative tools
# Design and creative tools.
clean_design_tools() {
safe_clean ~/Library/Caches/com.bohemiancoding.sketch3/* "Sketch cache"
safe_clean ~/Library/Application\ Support/com.bohemiancoding.sketch3/cache/* "Sketch app cache"
safe_clean ~/Library/Caches/Adobe/* "Adobe cache"
safe_clean ~/Library/Caches/com.adobe.*/* "Adobe app caches"
safe_clean ~/Library/Caches/com.figma.Desktop/* "Figma cache"
# Note: Raycast cache is protected - contains clipboard history (including images)
# Raycast cache is protected (clipboard history, images).
}
# Clean video editing tools
# Video editing tools.
clean_video_tools() {
safe_clean ~/Library/Caches/net.telestream.screenflow10/* "ScreenFlow cache"
safe_clean ~/Library/Caches/com.apple.FinalCut/* "Final Cut Pro cache"
safe_clean ~/Library/Caches/com.blackmagic-design.DaVinciResolve/* "DaVinci Resolve cache"
safe_clean ~/Library/Caches/com.adobe.PremierePro.*/* "Premiere Pro cache"
}
# Clean 3D and CAD tools
# 3D and CAD tools.
clean_3d_tools() {
safe_clean ~/Library/Caches/org.blenderfoundation.blender/* "Blender cache"
safe_clean ~/Library/Caches/com.maxon.cinema4d/* "Cinema 4D cache"
safe_clean ~/Library/Caches/com.autodesk.*/* "Autodesk cache"
safe_clean ~/Library/Caches/com.sketchup.*/* "SketchUp cache"
}
# Clean productivity apps
# Productivity apps.
clean_productivity_apps() {
safe_clean ~/Library/Caches/com.tw93.MiaoYan/* "MiaoYan cache"
safe_clean ~/Library/Caches/com.klee.desktop/* "Klee cache"
@@ -92,20 +89,18 @@ clean_productivity_apps() {
safe_clean ~/Library/Caches/com.filo.client/* "Filo cache"
safe_clean ~/Library/Caches/com.flomoapp.mac/* "Flomo cache"
}
# Clean music and media players (protects Spotify offline music)
# Music/media players (protect Spotify offline music).
clean_media_players() {
# Spotify cache protection: check for offline music indicators
local spotify_cache="$HOME/Library/Caches/com.spotify.client"
local spotify_data="$HOME/Library/Application Support/Spotify"
local has_offline_music=false
# Check for offline music database or large cache (>500MB)
# Heuristics: offline DB or large cache.
if [[ -f "$spotify_data/PersistentCache/Storage/offline.bnk" ]] ||
[[ -d "$spotify_data/PersistentCache/Storage" && -n "$(find "$spotify_data/PersistentCache/Storage" -type f -name "*.file" 2> /dev/null | head -1)" ]]; then
has_offline_music=true
elif [[ -d "$spotify_cache" ]]; then
local cache_size_kb
cache_size_kb=$(get_path_size_kb "$spotify_cache")
# Large cache (>500MB) likely contains offline music
if [[ $cache_size_kb -ge 512000 ]]; then
has_offline_music=true
fi
@@ -125,7 +120,7 @@ clean_media_players() {
safe_clean ~/Library/Caches/com.kugou.mac/* "Kugou Music cache"
safe_clean ~/Library/Caches/com.kuwo.mac/* "Kuwo Music cache"
}
# Clean video players
# Video players.
clean_video_players() {
safe_clean ~/Library/Caches/com.colliderli.iina "IINA cache"
safe_clean ~/Library/Caches/org.videolan.vlc "VLC cache"
@@ -136,7 +131,7 @@ clean_video_players() {
safe_clean ~/Library/Caches/com.douyu.*/* "Douyu cache"
safe_clean ~/Library/Caches/com.huya.*/* "Huya cache"
}
# Clean download managers
# Download managers.
clean_download_managers() {
safe_clean ~/Library/Caches/net.xmac.aria2gui "Aria2 cache"
safe_clean ~/Library/Caches/org.m0k.transmission "Transmission cache"
@@ -145,7 +140,7 @@ clean_download_managers() {
safe_clean ~/Library/Caches/com.folx.*/* "Folx cache"
safe_clean ~/Library/Caches/com.charlessoft.pacifist/* "Pacifist cache"
}
# Clean gaming platforms
# Gaming platforms.
clean_gaming_platforms() {
safe_clean ~/Library/Caches/com.valvesoftware.steam/* "Steam cache"
safe_clean ~/Library/Application\ Support/Steam/htmlcache/* "Steam web cache"
@@ -156,41 +151,41 @@ clean_gaming_platforms() {
safe_clean ~/Library/Caches/com.gog.galaxy/* "GOG Galaxy cache"
safe_clean ~/Library/Caches/com.riotgames.*/* "Riot Games cache"
}
# Clean translation and dictionary apps
# Translation/dictionary apps.
clean_translation_apps() {
safe_clean ~/Library/Caches/com.youdao.YoudaoDict "Youdao Dictionary cache"
safe_clean ~/Library/Caches/com.eudic.* "Eudict cache"
safe_clean ~/Library/Caches/com.bob-build.Bob "Bob Translation cache"
}
# Clean screenshot and screen recording tools
# Screenshot/recording tools.
clean_screenshot_tools() {
safe_clean ~/Library/Caches/com.cleanshot.* "CleanShot cache"
safe_clean ~/Library/Caches/com.reincubate.camo "Camo cache"
safe_clean ~/Library/Caches/com.xnipapp.xnip "Xnip cache"
}
# Clean email clients
# Email clients.
clean_email_clients() {
safe_clean ~/Library/Caches/com.readdle.smartemail-Mac "Spark cache"
safe_clean ~/Library/Caches/com.airmail.* "Airmail cache"
}
# Clean task management apps
# Task management apps.
clean_task_apps() {
safe_clean ~/Library/Caches/com.todoist.mac.Todoist "Todoist cache"
safe_clean ~/Library/Caches/com.any.do.* "Any.do cache"
}
# Clean shell and terminal utilities
# Shell/terminal utilities.
clean_shell_utils() {
safe_clean ~/.zcompdump* "Zsh completion cache"
safe_clean ~/.lesshst "less history"
safe_clean ~/.viminfo.tmp "Vim temporary files"
safe_clean ~/.wget-hsts "wget HSTS cache"
}
# Clean input method and system utilities
# Input methods and system utilities.
clean_system_utils() {
safe_clean ~/Library/Caches/com.runjuu.Input-Source-Pro/* "Input Source Pro cache"
safe_clean ~/Library/Caches/macos-wakatime.WakaTime/* "WakaTime cache"
}
# Clean note-taking apps
# Note-taking apps.
clean_note_apps() {
safe_clean ~/Library/Caches/notion.id/* "Notion cache"
safe_clean ~/Library/Caches/md.obsidian/* "Obsidian cache"
@@ -199,19 +194,19 @@ clean_note_apps() {
safe_clean ~/Library/Caches/com.evernote.*/* "Evernote cache"
safe_clean ~/Library/Caches/com.yinxiang.*/* "Yinxiang Note cache"
}
# Clean launcher and automation tools
# Launchers and automation tools.
clean_launcher_apps() {
safe_clean ~/Library/Caches/com.runningwithcrayons.Alfred/* "Alfred cache"
safe_clean ~/Library/Caches/cx.c3.theunarchiver/* "The Unarchiver cache"
}
# Clean remote desktop tools
# Remote desktop tools.
clean_remote_desktop() {
safe_clean ~/Library/Caches/com.teamviewer.*/* "TeamViewer cache"
safe_clean ~/Library/Caches/com.anydesk.*/* "AnyDesk cache"
safe_clean ~/Library/Caches/com.todesk.*/* "ToDesk cache"
safe_clean ~/Library/Caches/com.sunlogin.*/* "Sunlogin cache"
}
# Main function to clean all user GUI applications
# Main entry for GUI app cleanup.
clean_user_gui_applications() {
stop_section_spinner
clean_xcode_tools

View File

@@ -2,7 +2,6 @@
# Application Data Cleanup Module
set -euo pipefail
# Args: $1=target_dir, $2=label
# Clean .DS_Store (Finder metadata), home uses maxdepth 5, excludes slow paths, max 500 files
clean_ds_store_tree() {
local target="$1"
local label="$2"
@@ -15,7 +14,6 @@ clean_ds_store_tree() {
start_inline_spinner "Cleaning Finder metadata..."
spinner_active="true"
fi
# Build exclusion paths for find (skip common slow/large directories)
local -a exclude_paths=(
-path "*/Library/Application Support/MobileSync" -prune -o
-path "*/Library/Developer" -prune -o
@@ -24,13 +22,11 @@ clean_ds_store_tree() {
-path "*/.git" -prune -o
-path "*/Library/Caches" -prune -o
)
# Build find command to avoid unbound array expansion with set -u
local -a find_cmd=("command" "find" "$target")
if [[ "$target" == "$HOME" ]]; then
find_cmd+=("-maxdepth" "5")
fi
find_cmd+=("${exclude_paths[@]}" "-type" "f" "-name" ".DS_Store" "-print0")
# Find .DS_Store files with exclusions and depth limit
while IFS= read -r -d '' ds_file; do
local size
size=$(get_file_size "$ds_file")
@@ -61,14 +57,11 @@ clean_ds_store_tree() {
note_activity
fi
}
# Clean data for uninstalled apps (caches/logs/states older than 60 days)
# Protects system apps, major vendors, scans /Applications+running processes
# Max 100 items/pattern, 2s du timeout. Env: ORPHAN_AGE_THRESHOLD, DRY_RUN
# Orphaned app data (60+ days inactive). Env: ORPHAN_AGE_THRESHOLD, DRY_RUN
# Usage: scan_installed_apps "output_file"
# Scan system for installed application bundle IDs
scan_installed_apps() {
local installed_bundles="$1"
# Performance optimization: cache results for 5 minutes
# Cache installed app scan briefly to speed repeated runs.
local cache_file="$HOME/.cache/mole/installed_apps_cache"
local cache_age_seconds=300 # 5 minutes
if [[ -f "$cache_file" ]]; then
@@ -77,7 +70,6 @@ scan_installed_apps() {
local age=$((current_time - cache_mtime))
if [[ $age -lt $cache_age_seconds ]]; then
debug_log "Using cached app list (age: ${age}s)"
# Verify cache file is readable and not empty
if [[ -r "$cache_file" ]] && [[ -s "$cache_file" ]]; then
if cat "$cache_file" > "$installed_bundles" 2> /dev/null; then
return 0
@@ -90,26 +82,22 @@ scan_installed_apps() {
fi
fi
debug_log "Scanning installed applications (cache expired or missing)"
# Scan all Applications directories
local -a app_dirs=(
"/Applications"
"/System/Applications"
"$HOME/Applications"
)
# Create a temp dir for parallel results to avoid write contention
# Temp dir avoids write contention across parallel scans.
local scan_tmp_dir=$(create_temp_dir)
# Parallel scan for applications
local pids=()
local dir_idx=0
for app_dir in "${app_dirs[@]}"; do
[[ -d "$app_dir" ]] || continue
(
# Quickly find all .app bundles first
local -a app_paths=()
while IFS= read -r app_path; do
[[ -n "$app_path" ]] && app_paths+=("$app_path")
done < <(find "$app_dir" -name '*.app' -maxdepth 3 -type d 2> /dev/null)
# Read bundle IDs with PlistBuddy
local count=0
for app_path in "${app_paths[@]:-}"; do
local plist_path="$app_path/Contents/Info.plist"
@@ -124,7 +112,7 @@ scan_installed_apps() {
pids+=($!)
((dir_idx++))
done
# Get running applications and LaunchAgents in parallel
# Collect running apps and LaunchAgents to avoid false orphan cleanup.
(
local running_apps=$(run_with_timeout 5 osascript -e 'tell application "System Events" to get bundle identifier of every application process' 2> /dev/null || echo "")
echo "$running_apps" | tr ',' '\n' | sed -e 's/^ *//;s/ *$//' -e '/^$/d' > "$scan_tmp_dir/running.txt"
@@ -136,7 +124,6 @@ scan_installed_apps() {
xargs -I {} basename {} .plist > "$scan_tmp_dir/agents.txt" 2> /dev/null || true
) &
pids+=($!)
# Wait for all background scans to complete
debug_log "Waiting for ${#pids[@]} background processes: ${pids[*]}"
for pid in "${pids[@]}"; do
wait "$pid" 2> /dev/null || true
@@ -145,37 +132,30 @@ scan_installed_apps() {
cat "$scan_tmp_dir"/*.txt >> "$installed_bundles" 2> /dev/null || true
safe_remove "$scan_tmp_dir" true
sort -u "$installed_bundles" -o "$installed_bundles"
# Cache the results
ensure_user_dir "$(dirname "$cache_file")"
cp "$installed_bundles" "$cache_file" 2> /dev/null || true
local app_count=$(wc -l < "$installed_bundles" 2> /dev/null | tr -d ' ')
debug_log "Scanned $app_count unique applications"
}
# Usage: is_bundle_orphaned "bundle_id" "directory_path" "installed_bundles_file"
# Check if bundle is orphaned
is_bundle_orphaned() {
local bundle_id="$1"
local directory_path="$2"
local installed_bundles="$3"
# Skip system-critical and protected apps
if should_protect_data "$bundle_id"; then
return 1
fi
# Check if app exists in our scan
if grep -Fxq "$bundle_id" "$installed_bundles" 2> /dev/null; then
return 1
fi
# Check against centralized protected patterns (app_protection.sh)
if should_protect_data "$bundle_id"; then
return 1
fi
# Extra check for specific system bundles not covered by patterns
case "$bundle_id" in
loginwindow | dock | systempreferences | systemsettings | settings | controlcenter | finder | safari)
return 1
;;
esac
# Check file age - only clean if 60+ days inactive
if [[ -e "$directory_path" ]]; then
local last_modified_epoch=$(get_file_mtime "$directory_path")
local current_epoch=$(date +%s)
@@ -186,31 +166,23 @@ is_bundle_orphaned() {
fi
return 0
}
# Clean data for uninstalled apps (caches/logs/states older than 60 days)
# Max 100 items/pattern, 2s du timeout. Env: ORPHAN_AGE_THRESHOLD, DRY_RUN
# Protects system apps, major vendors, scans /Applications+running processes
# Orphaned app data sweep.
clean_orphaned_app_data() {
# Quick permission check - if we can't access Library folders, skip
if ! ls "$HOME/Library/Caches" > /dev/null 2>&1; then
stop_section_spinner
echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped: No permission to access Library folders"
return 0
fi
# Build list of installed/active apps
start_section_spinner "Scanning installed apps..."
local installed_bundles=$(create_temp_file)
scan_installed_apps "$installed_bundles"
stop_section_spinner
# Display scan results
local app_count=$(wc -l < "$installed_bundles" 2> /dev/null | tr -d ' ')
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Found $app_count active/installed apps"
# Track statistics
local orphaned_count=0
local total_orphaned_kb=0
# Unified orphaned resource scanner (caches, logs, states, webkit, HTTP, cookies)
start_section_spinner "Scanning orphaned app resources..."
# Define resource types to scan
# CRITICAL: NEVER add LaunchAgents or LaunchDaemons (breaks login items/startup apps)
# CRITICAL: NEVER add LaunchAgents or LaunchDaemons (breaks login items/startup apps).
local -a resource_types=(
"$HOME/Library/Caches|Caches|com.*:org.*:net.*:io.*"
"$HOME/Library/Logs|Logs|com.*:org.*:net.*:io.*"
@@ -222,38 +194,29 @@ clean_orphaned_app_data() {
orphaned_count=0
for resource_type in "${resource_types[@]}"; do
IFS='|' read -r base_path label patterns <<< "$resource_type"
# Check both existence and permission to avoid hanging
if [[ ! -d "$base_path" ]]; then
continue
fi
# Quick permission check - if we can't ls the directory, skip it
if ! ls "$base_path" > /dev/null 2>&1; then
continue
fi
# Build file pattern array
local -a file_patterns=()
IFS=':' read -ra pattern_arr <<< "$patterns"
for pat in "${pattern_arr[@]}"; do
file_patterns+=("$base_path/$pat")
done
# Scan and clean orphaned items
for item_path in "${file_patterns[@]}"; do
# Use shell glob (no ls needed)
# Limit iterations to prevent hanging on directories with too many files
local iteration_count=0
for match in $item_path; do
[[ -e "$match" ]] || continue
# Safety: limit iterations to prevent infinite loops on massive directories
((iteration_count++))
if [[ $iteration_count -gt $MOLE_MAX_ORPHAN_ITERATIONS ]]; then
break
fi
# Extract bundle ID from filename
local bundle_id=$(basename "$match")
bundle_id="${bundle_id%.savedState}"
bundle_id="${bundle_id%.binarycookies}"
if is_bundle_orphaned "$bundle_id" "$match" "$installed_bundles"; then
# Use timeout to prevent du from hanging on network mounts or problematic paths
local size_kb
size_kb=$(get_path_size_kb "$match")
if [[ -z "$size_kb" || "$size_kb" == "0" ]]; then

View File

@@ -4,13 +4,11 @@
# Skips if run within 7 days, runs cleanup/autoremove in parallel with 120s timeout
clean_homebrew() {
command -v brew > /dev/null 2>&1 || return 0
# Dry run mode - just indicate what would happen
if [[ "${DRY_RUN:-false}" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Homebrew · would cleanup and autoremove"
return 0
fi
# Smart caching: check if brew cleanup was run recently (within 7 days)
# Extended from 2 days to 7 days to reduce cleanup frequency
# Skip if cleaned recently to avoid repeated heavy operations.
local brew_cache_file="${HOME}/.cache/mole/brew_last_cleanup"
local cache_valid_days=7
local should_skip=false
@@ -27,20 +25,17 @@ clean_homebrew() {
fi
fi
[[ "$should_skip" == "true" ]] && return 0
# Quick pre-check: determine if cleanup is needed based on cache size (<50MB)
# Use timeout to prevent slow du on very large caches
# If timeout occurs, assume cache is large and run cleanup
# Skip cleanup if cache is small; still run autoremove.
local skip_cleanup=false
local brew_cache_size=0
if [[ -d ~/Library/Caches/Homebrew ]]; then
brew_cache_size=$(run_with_timeout 3 du -sk ~/Library/Caches/Homebrew 2> /dev/null | awk '{print $1}')
local du_exit=$?
# Skip cleanup (but still run autoremove) if cache is small
if [[ $du_exit -eq 0 && -n "$brew_cache_size" && "$brew_cache_size" -lt 51200 ]]; then
skip_cleanup=true
fi
fi
# Display appropriate spinner message
# Spinner reflects whether cleanup is skipped.
if [[ -t 1 ]]; then
if [[ "$skip_cleanup" == "true" ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Homebrew autoremove (cleanup skipped)..."
@@ -48,8 +43,8 @@ clean_homebrew() {
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Homebrew cleanup and autoremove..."
fi
fi
# Run cleanup/autoremove in parallel with a timeout guard.
local timeout_seconds=${MO_BREW_TIMEOUT:-120}
# Run brew cleanup and/or autoremove based on cache size
local brew_tmp_file autoremove_tmp_file
local brew_pid autoremove_pid
if [[ "$skip_cleanup" == "false" ]]; then
@@ -63,9 +58,7 @@ clean_homebrew() {
local elapsed=0
local brew_done=false
local autoremove_done=false
# Mark cleanup as done if it was skipped
[[ "$skip_cleanup" == "true" ]] && brew_done=true
# Wait for both to complete or timeout
while [[ "$brew_done" == "false" ]] || [[ "$autoremove_done" == "false" ]]; do
if [[ $elapsed -ge $timeout_seconds ]]; then
[[ -n "$brew_pid" ]] && kill -TERM $brew_pid 2> /dev/null || true
@@ -77,7 +70,6 @@ clean_homebrew() {
sleep 1
((elapsed++))
done
# Wait for processes to finish
local brew_success=false
if [[ "$skip_cleanup" == "false" && -n "$brew_pid" ]]; then
if wait $brew_pid 2> /dev/null; then
@@ -90,6 +82,7 @@ clean_homebrew() {
fi
if [[ -t 1 ]]; then stop_inline_spinner; fi
# Process cleanup output and extract metrics
# Summarize cleanup results.
if [[ "$skip_cleanup" == "true" ]]; then
# Cleanup was skipped due to small cache size
local size_mb=$((brew_cache_size / 1024))
@@ -111,6 +104,7 @@ clean_homebrew() {
echo -e " ${YELLOW}${ICON_WARNING}${NC} Homebrew cleanup timed out · run ${GRAY}brew cleanup${NC} manually"
fi
# Process autoremove output - only show if packages were removed
# Only surface autoremove output when packages were removed.
if [[ "$autoremove_success" == "true" && -f "$autoremove_tmp_file" ]]; then
local autoremove_output
autoremove_output=$(cat "$autoremove_tmp_file" 2> /dev/null || echo "")
@@ -124,6 +118,7 @@ clean_homebrew() {
fi
# Update cache timestamp on successful completion or when cleanup was intelligently skipped
# This prevents repeated cache size checks within the 7-day window
# Update cache timestamp when any work succeeded or was intentionally skipped.
if [[ "$skip_cleanup" == "true" ]] || [[ "$brew_success" == "true" ]] || [[ "$autoremove_success" == "true" ]]; then
ensure_user_file "$brew_cache_file"
date +%s > "$brew_cache_file"

View File

@@ -1,15 +1,11 @@
#!/bin/bash
# Cache Cleanup Module
set -euo pipefail
# Only runs once (uses ~/.cache/mole/permissions_granted flag)
# Trigger all TCC permission dialogs upfront to avoid random interruptions
# Preflight TCC prompts once to avoid mid-run interruptions.
check_tcc_permissions() {
# Only check in interactive mode
[[ -t 1 ]] || return 0
local permission_flag="$HOME/.cache/mole/permissions_granted"
# Skip if permissions were already granted
[[ -f "$permission_flag" ]] && return 0
# Key protected directories that require TCC approval
local -a tcc_dirs=(
"$HOME/Library/Caches"
"$HOME/Library/Logs"
@@ -17,8 +13,7 @@ check_tcc_permissions() {
"$HOME/Library/Containers"
"$HOME/.cache"
)
# Quick permission test - if first directory is accessible, likely others are too
# Use simple ls test instead of find to avoid triggering permission dialogs prematurely
# Quick permission probe (avoid deep scans).
local needs_permission_check=false
if ! ls "$HOME/Library/Caches" > /dev/null 2>&1; then
needs_permission_check=true
@@ -32,35 +27,30 @@ check_tcc_permissions() {
echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to continue: "
read -r
MOLE_SPINNER_PREFIX="" start_inline_spinner "Requesting permissions..."
# Trigger all TCC prompts upfront by accessing each directory
# Using find -maxdepth 1 ensures we touch the directory without deep scanning
# Touch each directory to trigger prompts without deep scanning.
for dir in "${tcc_dirs[@]}"; do
[[ -d "$dir" ]] && command find "$dir" -maxdepth 1 -type d > /dev/null 2>&1
done
stop_inline_spinner
echo ""
fi
# Mark permissions as granted (won't prompt again)
# Mark as granted to avoid repeat prompts.
ensure_user_file "$permission_flag"
return 0
}
# Args: $1=browser_name, $2=cache_path
# Clean browser Service Worker cache, protecting web editing tools (capcut, photopea, pixlr)
# Clean Service Worker cache while protecting critical web editors.
clean_service_worker_cache() {
local browser_name="$1"
local cache_path="$2"
[[ ! -d "$cache_path" ]] && return 0
local cleaned_size=0
local protected_count=0
# Find all cache directories and calculate sizes with timeout protection
while IFS= read -r cache_dir; do
[[ ! -d "$cache_dir" ]] && continue
# Extract domain from path using regex
# Pattern matches: letters/numbers, hyphens, then dot, then TLD
# Example: "abc123_https_example.com_0" → "example.com"
# Extract a best-effort domain name from cache folder.
local domain=$(basename "$cache_dir" | grep -oE '[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}' | head -1 || echo "")
local size=$(run_with_timeout 5 get_path_size_kb "$cache_dir")
# Check if domain is protected
local is_protected=false
for protected_domain in "${PROTECTED_SW_DOMAINS[@]}"; do
if [[ "$domain" == *"$protected_domain"* ]]; then
@@ -69,7 +59,6 @@ clean_service_worker_cache() {
break
fi
done
# Clean if not protected
if [[ "$is_protected" == "false" ]]; then
if [[ "$DRY_RUN" != "true" ]]; then
safe_remove "$cache_dir" true || true
@@ -78,7 +67,6 @@ clean_service_worker_cache() {
fi
done < <(run_with_timeout 10 sh -c "find '$cache_path' -type d -depth 2 2> /dev/null || true")
if [[ $cleaned_size -gt 0 ]]; then
# Temporarily stop spinner for clean output
local spinner_was_running=false
if [[ -t 1 && -n "${INLINE_SPINNER_PID:-}" ]]; then
stop_inline_spinner
@@ -95,17 +83,15 @@ clean_service_worker_cache() {
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $browser_name Service Worker (would clean ${cleaned_mb}MB, ${protected_count} protected)"
fi
note_activity
# Restart spinner if it was running
if [[ "$spinner_was_running" == "true" ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning browser Service Worker caches..."
fi
fi
}
# Uses maxdepth 3, excludes Library/.Trash/node_modules, 10s timeout per scan
# Clean Next.js (.next/cache) and Python (__pycache__) build caches
# Next.js/Python project caches with tight scan bounds and timeouts.
clean_project_caches() {
stop_inline_spinner 2> /dev/null || true
# Quick check: skip if user likely doesn't have development projects
# Fast pre-check before scanning the whole home dir.
local has_dev_projects=false
local -a common_dev_dirs=(
"$HOME/Code"
@@ -133,8 +119,7 @@ clean_project_caches() {
break
fi
done
# If no common dev directories found, perform feature-based detection
# Check for project markers in $HOME (node_modules, .git, target, etc.)
# Fallback: look for project markers near $HOME.
if [[ "$has_dev_projects" == "false" ]]; then
local -a project_markers=(
"node_modules"
@@ -153,7 +138,6 @@ clean_project_caches() {
spinner_active=true
fi
for marker in "${project_markers[@]}"; do
# Quick check with maxdepth 2 and 3s timeout to avoid slow scans
if run_with_timeout 3 sh -c "find '$HOME' -maxdepth 2 -name '$marker' -not -path '*/Library/*' -not -path '*/.Trash/*' 2>/dev/null | head -1" | grep -q .; then
has_dev_projects=true
break
@@ -162,7 +146,6 @@ clean_project_caches() {
if [[ "$spinner_active" == "true" ]]; then
stop_inline_spinner 2> /dev/null || true
fi
# If still no dev projects found, skip scanning
[[ "$has_dev_projects" == "false" ]] && return 0
fi
if [[ -t 1 ]]; then
@@ -174,7 +157,7 @@ clean_project_caches() {
local pycache_tmp_file
pycache_tmp_file=$(create_temp_file)
local find_timeout=10
# 1. Start Next.js search
# Parallel scans (Next.js and __pycache__).
(
command find "$HOME" -P -mount -type d -name ".next" -maxdepth 3 \
-not -path "*/Library/*" \
@@ -184,7 +167,6 @@ clean_project_caches() {
2> /dev/null || true
) > "$nextjs_tmp_file" 2>&1 &
local next_pid=$!
# 2. Start Python search
(
command find "$HOME" -P -mount -type d -name "__pycache__" -maxdepth 3 \
-not -path "*/Library/*" \
@@ -194,7 +176,6 @@ clean_project_caches() {
2> /dev/null || true
) > "$pycache_tmp_file" 2>&1 &
local py_pid=$!
# 3. Wait for both with timeout (using smaller intervals for better responsiveness)
local elapsed=0
local check_interval=0.2 # Check every 200ms instead of 1s for smoother experience
while [[ $(echo "$elapsed < $find_timeout" | awk '{print ($1 < $2)}') -eq 1 ]]; do
@@ -204,12 +185,10 @@ clean_project_caches() {
sleep $check_interval
elapsed=$(echo "$elapsed + $check_interval" | awk '{print $1 + $2}')
done
# 4. Clean up any stuck processes
# Kill stuck scans after timeout.
for pid in $next_pid $py_pid; do
if kill -0 "$pid" 2> /dev/null; then
# Send TERM signal first
kill -TERM "$pid" 2> /dev/null || true
# Wait up to 2 seconds for graceful termination
local grace_period=0
while [[ $grace_period -lt 20 ]]; do
if ! kill -0 "$pid" 2> /dev/null; then
@@ -218,11 +197,9 @@ clean_project_caches() {
sleep 0.1
((grace_period++))
done
# Force kill if still running
if kill -0 "$pid" 2> /dev/null; then
kill -KILL "$pid" 2> /dev/null || true
fi
# Final wait (should be instant now)
wait "$pid" 2> /dev/null || true
else
wait "$pid" 2> /dev/null || true
@@ -231,11 +208,9 @@ clean_project_caches() {
if [[ -t 1 ]]; then
stop_inline_spinner
fi
# 5. Process Next.js results
while IFS= read -r next_dir; do
[[ -d "$next_dir/cache" ]] && safe_clean "$next_dir/cache"/* "Next.js build cache" || true
done < "$nextjs_tmp_file"
# 6. Process Python results
while IFS= read -r pycache; do
[[ -d "$pycache" ]] && safe_clean "$pycache"/* "Python bytecode cache" || true
done < "$pycache_tmp_file"

View File

@@ -1,11 +1,7 @@
#!/bin/bash
# Developer Tools Cleanup Module
set -euo pipefail
# Helper function to clean tool caches using their built-in commands
# Args: $1 - description, $@ - command to execute
# Env: DRY_RUN
# so we just report the action if we can't easily find a path)
# Note: Try to estimate potential savings (many tool caches don't have a direct path,
# Tool cache helper (respects DRY_RUN).
clean_tool_cache() {
local description="$1"
shift
@@ -18,50 +14,38 @@ clean_tool_cache() {
fi
return 0
}
# Clean npm cache (command + directories)
# Env: DRY_RUN
# npm cache clean clears official npm cache, safe_clean handles alternative package managers
# npm/pnpm/yarn/bun caches.
clean_dev_npm() {
if command -v npm > /dev/null 2>&1; then
# clean_tool_cache now calculates size before cleanup for better statistics
clean_tool_cache "npm cache" npm cache clean --force
note_activity
fi
# Clean pnpm store cache
local pnpm_default_store=~/Library/pnpm/store
if command -v pnpm > /dev/null 2>&1; then
# Use pnpm's built-in prune command
clean_tool_cache "pnpm cache" pnpm store prune
# Get the actual store path to check if default is orphaned
local pnpm_store_path
start_section_spinner "Checking store path..."
pnpm_store_path=$(run_with_timeout 2 pnpm store path 2> /dev/null) || pnpm_store_path=""
stop_section_spinner
# If store path is different from default, clean the orphaned default
if [[ -n "$pnpm_store_path" && "$pnpm_store_path" != "$pnpm_default_store" ]]; then
safe_clean "$pnpm_default_store"/* "Orphaned pnpm store"
fi
else
# pnpm not installed, clean default location
safe_clean "$pnpm_default_store"/* "pnpm store"
fi
note_activity
# Clean alternative package manager caches
safe_clean ~/.tnpm/_cacache/* "tnpm cache directory"
safe_clean ~/.tnpm/_logs/* "tnpm logs"
safe_clean ~/.yarn/cache/* "Yarn cache"
safe_clean ~/.bun/install/cache/* "Bun cache"
}
# Clean Python/pip cache (command + directories)
# Env: DRY_RUN
# pip cache purge clears official pip cache, safe_clean handles other Python tools
# Python/pip ecosystem caches.
clean_dev_python() {
if command -v pip3 > /dev/null 2>&1; then
# clean_tool_cache now calculates size before cleanup for better statistics
clean_tool_cache "pip cache" bash -c 'pip3 cache purge >/dev/null 2>&1 || true'
note_activity
fi
# Clean Python ecosystem caches
safe_clean ~/.pyenv/cache/* "pyenv cache"
safe_clean ~/.cache/poetry/* "Poetry cache"
safe_clean ~/.cache/uv/* "uv cache"
@@ -76,28 +60,23 @@ clean_dev_python() {
safe_clean ~/anaconda3/pkgs/* "Anaconda packages cache"
safe_clean ~/.cache/wandb/* "Weights & Biases cache"
}
# Clean Go cache (command + directories)
# Env: DRY_RUN
# go clean handles build and module caches comprehensively
# Go build/module caches.
clean_dev_go() {
if command -v go > /dev/null 2>&1; then
# clean_tool_cache now calculates size before cleanup for better statistics
clean_tool_cache "Go cache" bash -c 'go clean -modcache >/dev/null 2>&1 || true; go clean -cache >/dev/null 2>&1 || true'
note_activity
fi
}
# Clean Rust/cargo cache directories
# Rust/cargo caches.
clean_dev_rust() {
safe_clean ~/.cargo/registry/cache/* "Rust cargo cache"
safe_clean ~/.cargo/git/* "Cargo git cache"
safe_clean ~/.rustup/downloads/* "Rust downloads cache"
}
# Env: DRY_RUN
# Clean Docker cache (command + directories)
# Docker caches (guarded by daemon check).
clean_dev_docker() {
if command -v docker > /dev/null 2>&1; then
if [[ "$DRY_RUN" != "true" ]]; then
# Check if Docker daemon is running (with timeout to prevent hanging)
start_section_spinner "Checking Docker daemon..."
local docker_running=false
if run_with_timeout 3 docker info > /dev/null 2>&1; then
@@ -107,7 +86,6 @@ clean_dev_docker() {
if [[ "$docker_running" == "true" ]]; then
clean_tool_cache "Docker build cache" docker builder prune -af
else
# Docker not running - silently skip without user interaction
debug_log "Docker daemon not running, skipping Docker cache cleanup"
fi
else
@@ -117,8 +95,7 @@ clean_dev_docker() {
fi
safe_clean ~/.docker/buildx/cache/* "Docker BuildX cache"
}
# Env: DRY_RUN
# Clean Nix package manager
# Nix garbage collection.
clean_dev_nix() {
if command -v nix-collect-garbage > /dev/null 2>&1; then
if [[ "$DRY_RUN" != "true" ]]; then
@@ -129,7 +106,7 @@ clean_dev_nix() {
note_activity
fi
}
# Clean cloud CLI tools cache
# Cloud CLI caches.
clean_dev_cloud() {
safe_clean ~/.kube/cache/* "Kubernetes cache"
safe_clean ~/.local/share/containers/storage/tmp/* "Container storage temp"
@@ -137,7 +114,7 @@ clean_dev_cloud() {
safe_clean ~/.config/gcloud/logs/* "Google Cloud logs"
safe_clean ~/.azure/logs/* "Azure CLI logs"
}
# Clean frontend build tool caches
# Frontend build caches.
clean_dev_frontend() {
safe_clean ~/.cache/typescript/* "TypeScript cache"
safe_clean ~/.cache/electron/* "Electron cache"
@@ -151,40 +128,29 @@ clean_dev_frontend() {
safe_clean ~/.cache/eslint/* "ESLint cache"
safe_clean ~/.cache/prettier/* "Prettier cache"
}
# Clean mobile development tools
# iOS simulator cleanup can free significant space (70GB+ in some cases)
# Simulator runtime caches can grow large over time
# DeviceSupport files accumulate for each iOS version connected
# Mobile dev caches (can be large).
clean_dev_mobile() {
# Clean Xcode unavailable simulators
# Removes old and unused local iOS simulator data from old unused runtimes
# Can free up significant space (70GB+ in some cases)
if command -v xcrun > /dev/null 2>&1; then
debug_log "Checking for unavailable Xcode simulators"
if [[ "$DRY_RUN" == "true" ]]; then
clean_tool_cache "Xcode unavailable simulators" xcrun simctl delete unavailable
else
start_section_spinner "Checking unavailable simulators..."
# Run command manually to control UI output order
if xcrun simctl delete unavailable > /dev/null 2>&1; then
stop_section_spinner
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators"
else
stop_section_spinner
# Silently fail or log error if needed, matching clean_tool_cache behavior
fi
fi
note_activity
fi
# Clean iOS DeviceSupport - more comprehensive cleanup
# DeviceSupport directories store debug symbols for each iOS version
# Safe to clean caches and logs, but preserve device support files themselves
# DeviceSupport caches/logs (preserve core support files).
safe_clean ~/Library/Developer/Xcode/iOS\ DeviceSupport/*/Symbols/System/Library/Caches/* "iOS device symbol cache"
safe_clean ~/Library/Developer/Xcode/iOS\ DeviceSupport/*.log "iOS device support logs"
safe_clean ~/Library/Developer/Xcode/watchOS\ DeviceSupport/*/Symbols/System/Library/Caches/* "watchOS device symbol cache"
safe_clean ~/Library/Developer/Xcode/tvOS\ DeviceSupport/*/Symbols/System/Library/Caches/* "tvOS device symbol cache"
# Clean simulator runtime caches
# RuntimeRoot caches can accumulate system library caches
# Simulator runtime caches.
safe_clean ~/Library/Developer/CoreSimulator/Profiles/Runtimes/*/Contents/Resources/RuntimeRoot/System/Library/Caches/* "Simulator runtime cache"
safe_clean ~/Library/Caches/Google/AndroidStudio*/* "Android Studio cache"
safe_clean ~/Library/Caches/CocoaPods/* "CocoaPods cache"
@@ -194,14 +160,14 @@ clean_dev_mobile() {
safe_clean ~/Library/Developer/Xcode/UserData/IB\ Support/* "Xcode Interface Builder cache"
safe_clean ~/.cache/swift-package-manager/* "Swift package manager cache"
}
# Clean JVM ecosystem tools
# JVM ecosystem caches.
clean_dev_jvm() {
safe_clean ~/.gradle/caches/* "Gradle caches"
safe_clean ~/.gradle/daemon/* "Gradle daemon logs"
safe_clean ~/.sbt/* "SBT cache"
safe_clean ~/.ivy2/cache/* "Ivy cache"
}
# Clean other language tools
# Other language tool caches.
clean_dev_other_langs() {
safe_clean ~/.bundle/cache/* "Ruby Bundler cache"
safe_clean ~/.composer/cache/* "PHP Composer cache"
@@ -211,7 +177,7 @@ clean_dev_other_langs() {
safe_clean ~/.cache/zig/* "Zig cache"
safe_clean ~/Library/Caches/deno/* "Deno cache"
}
# Clean CI/CD and DevOps tools
# CI/CD and DevOps caches.
clean_dev_cicd() {
safe_clean ~/.cache/terraform/* "Terraform cache"
safe_clean ~/.grafana/cache/* "Grafana cache"
@@ -222,7 +188,7 @@ clean_dev_cicd() {
safe_clean ~/.circleci/cache/* "CircleCI cache"
safe_clean ~/.sonar/* "SonarQube cache"
}
# Clean database tools
# Database tool caches.
clean_dev_database() {
safe_clean ~/Library/Caches/com.sequel-ace.sequel-ace/* "Sequel Ace cache"
safe_clean ~/Library/Caches/com.eggerapps.Sequel-Pro/* "Sequel Pro cache"
@@ -231,7 +197,7 @@ clean_dev_database() {
safe_clean ~/Library/Caches/com.dbeaver.* "DBeaver cache"
safe_clean ~/Library/Caches/com.redis.RedisInsight "Redis Insight cache"
}
# Clean API/network debugging tools
# API/debugging tool caches.
clean_dev_api_tools() {
safe_clean ~/Library/Caches/com.postmanlabs.mac/* "Postman cache"
safe_clean ~/Library/Caches/com.konghq.insomnia/* "Insomnia cache"
@@ -240,7 +206,7 @@ clean_dev_api_tools() {
safe_clean ~/Library/Caches/com.charlesproxy.charles/* "Charles Proxy cache"
safe_clean ~/Library/Caches/com.proxyman.NSProxy/* "Proxyman cache"
}
# Clean misc dev tools
# Misc dev tool caches.
clean_dev_misc() {
safe_clean ~/Library/Caches/com.unity3d.*/* "Unity cache"
safe_clean ~/Library/Caches/com.mongodb.compass/* "MongoDB Compass cache"
@@ -250,7 +216,7 @@ clean_dev_misc() {
safe_clean ~/Library/Caches/KSCrash/* "KSCrash reports"
safe_clean ~/Library/Caches/com.crashlytics.data/* "Crashlytics data"
}
# Clean shell and version control
# Shell and VCS leftovers.
clean_dev_shell() {
safe_clean ~/.gitconfig.lock "Git config lock"
safe_clean ~/.gitconfig.bak* "Git config backup"
@@ -260,28 +226,20 @@ clean_dev_shell() {
safe_clean ~/.zsh_history.bak* "Zsh history backup"
safe_clean ~/.cache/pre-commit/* "pre-commit cache"
}
# Clean network utilities
# Network tool caches.
clean_dev_network() {
safe_clean ~/.cache/curl/* "curl cache"
safe_clean ~/.cache/wget/* "wget cache"
safe_clean ~/Library/Caches/curl/* "macOS curl cache"
safe_clean ~/Library/Caches/wget/* "macOS wget cache"
}
# Clean orphaned SQLite temporary files (-shm and -wal files)
# Strategy: Only clean truly orphaned temp files where base database is missing
# Env: DRY_RUN
# This is fast and safe - skip complex checks for files with existing base DB
# Orphaned SQLite temp files (-shm/-wal). Disabled due to low ROI.
clean_sqlite_temp_files() {
# Skip this cleanup due to low ROI (收益比低,经常没东西可清理)
# Find scan is still slow even optimized, and orphaned files are rare
return 0
}
# Main developer tools cleanup function
# Env: DRY_RUN
# Calls all specialized cleanup functions
# Main developer tools cleanup sequence.
clean_developer_tools() {
stop_section_spinner
# Clean SQLite temporary files first
clean_sqlite_temp_files
clean_dev_npm
clean_dev_python
@@ -292,7 +250,6 @@ clean_developer_tools() {
clean_dev_nix
clean_dev_shell
clean_dev_frontend
# Project build caches (delegated to clean_caches module)
clean_project_caches
clean_dev_mobile
clean_dev_jvm
@@ -302,22 +259,17 @@ clean_developer_tools() {
clean_dev_api_tools
clean_dev_network
clean_dev_misc
# Homebrew caches and cleanup (delegated to clean_brew module)
safe_clean ~/Library/Caches/Homebrew/* "Homebrew cache"
# Clean Homebrew locks intelligently (avoid repeated sudo prompts)
# Clean Homebrew locks without repeated sudo prompts.
local brew_lock_dirs=(
"/opt/homebrew/var/homebrew/locks"
"/usr/local/var/homebrew/locks"
)
for lock_dir in "${brew_lock_dirs[@]}"; do
if [[ -d "$lock_dir" && -w "$lock_dir" ]]; then
# User can write, safe to clean
safe_clean "$lock_dir"/* "Homebrew lock files"
elif [[ -d "$lock_dir" ]]; then
# Directory exists but not writable. Check if empty to avoid noise.
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"
fi
fi

View File

@@ -1,6 +1,6 @@
#!/bin/bash
# Project Purge Module (mo purge)
# Removes heavy project build artifacts and dependencies
# Project Purge Module (mo purge).
# Removes heavy project build artifacts and dependencies.
set -euo pipefail
PROJECT_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -10,7 +10,7 @@ if ! command -v ensure_user_dir > /dev/null 2>&1; then
source "$CORE_LIB_DIR/common.sh"
fi
# Targets to look for (heavy build artifacts)
# Targets to look for (heavy build artifacts).
readonly PURGE_TARGETS=(
"node_modules"
"target" # Rust, Maven
@@ -29,12 +29,12 @@ readonly PURGE_TARGETS=(
".parcel-cache" # Parcel bundler
".dart_tool" # Flutter/Dart build cache
)
# Minimum age in days before considering for cleanup
# Minimum age in days before considering for cleanup.
readonly MIN_AGE_DAYS=7
# Scan depth defaults (relative to search root)
# Scan depth defaults (relative to search root).
readonly PURGE_MIN_DEPTH_DEFAULT=2
readonly PURGE_MAX_DEPTH_DEFAULT=8
# Search paths (default, can be overridden via config file)
# Search paths (default, can be overridden via config file).
readonly DEFAULT_PURGE_SEARCH_PATHS=(
"$HOME/www"
"$HOME/dev"
@@ -46,13 +46,13 @@ readonly DEFAULT_PURGE_SEARCH_PATHS=(
"$HOME/Development"
)
# Config file for custom purge paths
# Config file for custom purge paths.
readonly PURGE_CONFIG_FILE="$HOME/.config/mole/purge_paths"
# Global array to hold actual search paths
# Resolved search paths.
PURGE_SEARCH_PATHS=()
# Project indicator files (if a directory contains these, it's likely a project)
# Project indicators for container detection.
readonly PROJECT_INDICATORS=(
"package.json"
"Cargo.toml"
@@ -68,12 +68,12 @@ readonly PROJECT_INDICATORS=(
".git"
)
# Check if a directory contains projects (directly or in subdirectories)
# Check if a directory contains projects (directly or in subdirectories).
is_project_container() {
local dir="$1"
local max_depth="${2:-2}"
# Skip hidden directories and system directories
# Skip hidden/system directories.
local basename
basename=$(basename "$dir")
[[ "$basename" == .* ]] && return 1
@@ -84,7 +84,7 @@ is_project_container() {
[[ "$basename" == "Pictures" ]] && return 1
[[ "$basename" == "Public" ]] && return 1
# Build find expression with all indicators (single find call for efficiency)
# Single find expression for indicators.
local -a find_args=("$dir" "-maxdepth" "$max_depth" "(")
local first=true
for indicator in "${PROJECT_INDICATORS[@]}"; do
@@ -97,7 +97,6 @@ is_project_container() {
done
find_args+=(")" "-print" "-quit")
# Single find call to check all indicators at once
if find "${find_args[@]}" 2> /dev/null | grep -q .; then
return 0
fi
@@ -105,24 +104,22 @@ is_project_container() {
return 1
}
# Discover project directories in $HOME
# Discover project directories in $HOME.
discover_project_dirs() {
local -a discovered=()
# First check default paths that exist
for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do
if [[ -d "$path" ]]; then
discovered+=("$path")
fi
done
# Then scan $HOME for other project containers (depth 1)
# Scan $HOME for other containers (depth 1).
local dir
for dir in "$HOME"/*/; do
[[ ! -d "$dir" ]] && continue
dir="${dir%/}" # Remove trailing slash
# Skip if already in defaults
local already_found=false
for existing in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do
if [[ "$dir" == "$existing" ]]; then
@@ -132,17 +129,15 @@ discover_project_dirs() {
done
[[ "$already_found" == "true" ]] && continue
# Check if this directory contains projects
if is_project_container "$dir" 2; then
discovered+=("$dir")
fi
done
# Return unique paths
printf '%s\n' "${discovered[@]}" | sort -u
}
# Save discovered paths to config
# Save discovered paths to config.
save_discovered_paths() {
local -a paths=("$@")
@@ -166,26 +161,20 @@ EOF
load_purge_config() {
PURGE_SEARCH_PATHS=()
# Try loading from config file
if [[ -f "$PURGE_CONFIG_FILE" ]]; then
while IFS= read -r line; do
# Remove leading/trailing whitespace
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
# Skip empty lines and comments
[[ -z "$line" || "$line" =~ ^# ]] && continue
# Expand tilde to HOME
line="${line/#\~/$HOME}"
PURGE_SEARCH_PATHS+=("$line")
done < "$PURGE_CONFIG_FILE"
fi
# If no paths loaded, auto-discover and save
if [[ ${#PURGE_SEARCH_PATHS[@]} -eq 0 ]]; then
# Show discovery message if running interactively
if [[ -t 1 ]] && [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]]; then
echo -e "${GRAY}First run: discovering project directories...${NC}" >&2
fi
@@ -197,47 +186,37 @@ load_purge_config() {
if [[ ${#discovered[@]} -gt 0 ]]; then
PURGE_SEARCH_PATHS=("${discovered[@]}")
# Save for next time
save_discovered_paths "${discovered[@]}"
if [[ -t 1 ]] && [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]]; then
echo -e "${GRAY}Found ${#discovered[@]} project directories, saved to config${NC}" >&2
fi
else
# Fallback to defaults if nothing found
PURGE_SEARCH_PATHS=("${DEFAULT_PURGE_SEARCH_PATHS[@]}")
fi
fi
}
# Initialize paths on script load
# Initialize paths on script load.
load_purge_config
# Args: $1 - path to check
# Check if path is safe to clean (must be inside a project directory)
# Safe cleanup requires the path be inside a project directory.
is_safe_project_artifact() {
local path="$1"
local search_path="$2"
# Path must be absolute
if [[ "$path" != /* ]]; then
return 1
fi
# Must not be a direct child of HOME directory
# e.g., ~/.gradle is NOT safe, but ~/Projects/foo/.gradle IS safe
# Must not be a direct child of the search root.
local relative_path="${path#"$search_path"/}"
local depth=$(echo "$relative_path" | tr -cd '/' | wc -c)
# Require at least 1 level deep (inside a project folder)
# e.g., ~/www/weekly/node_modules is OK (depth >= 1)
# but ~/www/node_modules is NOT OK (depth < 1)
if [[ $depth -lt 1 ]]; then
return 1
fi
return 0
}
# Fast scan using fd or optimized find
# Args: $1 - search path, $2 - output file
# Args: $1 - search path, $2 - output file
# Scan for purge targets using strict project boundary checks
# Scan purge targets using fd (fast) or pruned find.
scan_purge_targets() {
local search_path="$1"
local output_file="$2"
@@ -255,7 +234,6 @@ scan_purge_targets() {
if [[ ! -d "$search_path" ]]; then
return
fi
# Use fd for fast parallel search if available
if command -v fd > /dev/null 2>&1; then
local fd_args=(
"--absolute-path"
@@ -273,47 +251,28 @@ scan_purge_targets() {
for target in "${PURGE_TARGETS[@]}"; do
fd_args+=("-g" "$target")
done
# Run fd command
fd "${fd_args[@]}" . "$search_path" 2> /dev/null | while IFS= read -r item; do
if is_safe_project_artifact "$item" "$search_path"; then
echo "$item"
fi
done | filter_nested_artifacts > "$output_file"
else
# Fallback to optimized find with pruning
# This prevents descending into heavily nested dirs like node_modules once found,
# providing a massive speedup (O(project_dirs) vs O(files)).
# Pruned find avoids descending into heavy directories.
local prune_args=()
# 1. Directories to prune (ignore completely)
local prune_dirs=(".git" "Library" ".Trash" "Applications")
for dir in "${prune_dirs[@]}"; do
# -name "DIR" -prune -o
prune_args+=("-name" "$dir" "-prune" "-o")
done
# 2. Targets to find (print AND prune)
# If we find node_modules, we print it and STOP looking inside it
for target in "${PURGE_TARGETS[@]}"; do
# -name "TARGET" -print -prune -o
prune_args+=("-name" "$target" "-print" "-prune" "-o")
done
# Run find command
# Logic: ( prune_pattern -prune -o target_pattern -print -prune )
# Note: We rely on implicit recursion for directories that don't match any pattern.
# -print is only called explicitly on targets.
# Removing the trailing -o from loop construction if necessary?
# Actually my loop adds -o at the end. I need to handle that.
# Let's verify the array construction.
# Re-building args cleanly:
local find_expr=()
# Excludes
for dir in "${prune_dirs[@]}"; do
find_expr+=("-name" "$dir" "-prune" "-o")
done
# Targets
local i=0
for target in "${PURGE_TARGETS[@]}"; do
find_expr+=("-name" "$target" "-print" "-prune")
# Add -o unless it's the very last item of targets
if [[ $i -lt $((${#PURGE_TARGETS[@]} - 1)) ]]; then
find_expr+=("-o")
fi
@@ -327,15 +286,12 @@ scan_purge_targets() {
done | filter_nested_artifacts > "$output_file"
fi
}
# Filter out nested artifacts (e.g. node_modules inside node_modules)
# Filter out nested artifacts (e.g. node_modules inside node_modules).
filter_nested_artifacts() {
while IFS= read -r item; do
local parent_dir=$(dirname "$item")
local is_nested=false
for target in "${PURGE_TARGETS[@]}"; do
# Check if parent directory IS a target or IS INSIDE a target
# e.g. .../node_modules/foo/node_modules -> parent has node_modules
# Use more strict matching to avoid false positives like "my_node_modules_backup"
if [[ "$parent_dir" == *"/$target/"* || "$parent_dir" == *"/$target" ]]; then
is_nested=true
break
@@ -347,14 +303,13 @@ filter_nested_artifacts() {
done
}
# Args: $1 - path
# Check if a path was modified recently (safety check)
# Check if a path was modified recently (safety check).
is_recently_modified() {
local path="$1"
local age_days=$MIN_AGE_DAYS
if [[ ! -e "$path" ]]; then
return 1
fi
# Get modification time using base.sh helper (handles GNU vs BSD stat)
local mod_time
mod_time=$(get_file_mtime "$path")
local current_time=$(date +%s)
@@ -367,7 +322,7 @@ is_recently_modified() {
fi
}
# Args: $1 - path
# Get human-readable size of directory
# Get directory size in KB.
get_dir_size_kb() {
local path="$1"
if [[ -d "$path" ]]; then
@@ -376,10 +331,7 @@ get_dir_size_kb() {
echo "0"
fi
}
# Simple category selector (for purge only)
# Args: category names and metadata as arrays (passed via global vars)
# Uses PURGE_RECENT_CATEGORIES to mark categories with recent items (default unselected)
# Returns: selected indices in PURGE_SELECTION_RESULT (comma-separated)
# Purge category selector.
select_purge_categories() {
local -a categories=("$@")
local total_items=${#categories[@]}
@@ -388,8 +340,7 @@ select_purge_categories() {
return 1
fi
# Calculate items per page based on terminal height
# Reserved: header(2) + blank(2) + footer(1) = 5 rows
# Calculate items per page based on terminal height.
_get_items_per_page() {
local term_height=24
if [[ -t 0 ]] || [[ -t 2 ]]; then

View File

@@ -1,11 +1,9 @@
#!/bin/bash
# System-Level Cleanup Module
# Deep system cleanup (requires sudo) and Time Machine failed backups
# System-Level Cleanup Module (requires sudo).
set -euo pipefail
# Deep system cleanup (requires sudo)
# System caches, logs, and temp files.
clean_deep_system() {
stop_section_spinner
# Clean old system caches
local cache_cleaned=0
safe_sudo_find_delete "/Library/Caches" "*.cache" "$MOLE_TEMP_FILE_AGE_DAYS" "f" && cache_cleaned=1 || true
safe_sudo_find_delete "/Library/Caches" "*.tmp" "$MOLE_TEMP_FILE_AGE_DAYS" "f" && cache_cleaned=1 || true
@@ -15,7 +13,6 @@ clean_deep_system() {
safe_sudo_find_delete "/private/tmp" "*" "${MOLE_TEMP_FILE_AGE_DAYS}" "f" && tmp_cleaned=1 || true
safe_sudo_find_delete "/private/var/tmp" "*" "${MOLE_TEMP_FILE_AGE_DAYS}" "f" && tmp_cleaned=1 || true
[[ $tmp_cleaned -eq 1 ]] && log_success "System temp files"
# Clean crash reports
safe_sudo_find_delete "/Library/Logs/DiagnosticReports" "*" "$MOLE_CRASH_REPORT_AGE_DAYS" "f" || true
log_success "System crash reports"
safe_sudo_find_delete "/private/var/log" "*.log" "$MOLE_LOG_AGE_DAYS" "f" || true
@@ -91,18 +88,15 @@ clean_deep_system() {
stop_section_spinner
[[ $diag_logs_cleaned -eq 1 ]] && log_success "System diagnostic trace logs"
}
# Clean incomplete Time Machine backups
# Incomplete Time Machine backups.
clean_time_machine_failed_backups() {
local tm_cleaned=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)
start_section_spinner "Checking Time Machine configuration..."
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
@@ -119,7 +113,6 @@ clean_time_machine_failed_backups() {
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
return 0
fi
# Skip if backup is running (check actual Running status, not just daemon existence)
if tmutil status 2> /dev/null | grep -q "Running = 1"; then
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
@@ -127,22 +120,19 @@ clean_time_machine_failed_backups() {
echo -e " ${YELLOW}!${NC} Time Machine backup in progress, skipping cleanup"
return 0
fi
# Update spinner message for volume scanning
if [[ "$spinner_active" == "true" ]]; then
start_section_spinner "Checking backup volumes..."
fi
# Fast pre-scan: check which volumes have Backups.backupdb (avoid expensive tmutil checks)
# Fast pre-scan for backup volumes to avoid slow tmutil checks.
local -a backup_volumes=()
for volume in /Volumes/*; do
[[ -d "$volume" ]] || continue
[[ "$volume" == "/Volumes/MacintoshHD" || "$volume" == "/" ]] && continue
[[ -L "$volume" ]] && continue
# Quick check: does this volume have backup directories?
if [[ -d "$volume/Backups.backupdb" ]] || [[ -d "$volume/.MobileBackups" ]]; then
backup_volumes+=("$volume")
fi
done
# If no backup volumes found, stop spinner and return
if [[ ${#backup_volumes[@]} -eq 0 ]]; then
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
@@ -150,23 +140,20 @@ clean_time_machine_failed_backups() {
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" ]]; then
start_section_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 | unknown) continue ;;
esac
# HFS+ style backups (Backups.backupdb)
local backupdb_dir="$volume/Backups.backupdb"
if [[ -d "$backupdb_dir" ]]; then
while IFS= read -r inprogress_file; do
[[ -d "$inprogress_file" ]] || continue
# Only delete old incomplete 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))
@@ -175,7 +162,6 @@ clean_time_machine_failed_backups() {
fi
local size_kb=$(get_path_size_kb "$inprogress_file")
[[ "$size_kb" -le 0 ]] && continue
# Stop spinner before first output
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
spinner_active=false
@@ -188,7 +174,6 @@ clean_time_machine_failed_backups() {
note_activity
continue
fi
# Real deletion
if ! command -v tmutil > /dev/null 2>&1; then
echo -e " ${YELLOW}!${NC} tmutil not available, skipping: $backup_name"
continue
@@ -205,17 +190,15 @@ clean_time_machine_failed_backups() {
fi
done < <(run_with_timeout 15 find "$backupdb_dir" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true)
fi
# APFS style backups (.backupbundle or .sparsebundle)
# APFS bundles.
for bundle in "$volume"/*.backupbundle "$volume"/*.sparsebundle; do
[[ -e "$bundle" ]] || continue
[[ -d "$bundle" ]] || continue
# Check if bundle is mounted
local bundle_name=$(basename "$bundle")
local mounted_path=$(hdiutil info 2> /dev/null | grep -A 5 "image-path.*$bundle_name" | grep "/Volumes/" | awk '{print $1}' | head -1 || echo "")
if [[ -n "$mounted_path" && -d "$mounted_path" ]]; then
while IFS= read -r inprogress_file; do
[[ -d "$inprogress_file" ]] || continue
# 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))
@@ -224,7 +207,6 @@ clean_time_machine_failed_backups() {
fi
local size_kb=$(get_path_size_kb "$inprogress_file")
[[ "$size_kb" -le 0 ]] && continue
# Stop spinner before first output
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
spinner_active=false
@@ -237,7 +219,6 @@ clean_time_machine_failed_backups() {
note_activity
continue
fi
# Real deletion
if ! command -v tmutil > /dev/null 2>&1; then
continue
fi
@@ -255,7 +236,6 @@ clean_time_machine_failed_backups() {
fi
done
done
# Stop spinner if still active (no backups found)
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
fi
@@ -263,33 +243,27 @@ clean_time_machine_failed_backups() {
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
fi
}
# Clean local APFS snapshots (keep the most recent snapshot)
# Local APFS snapshots (keep the most recent).
clean_local_snapshots() {
# Check if tmutil is available
if ! command -v tmutil > /dev/null 2>&1; then
return 0
fi
start_section_spinner "Checking local snapshots..."
# Check for local snapshots
local snapshot_list
snapshot_list=$(tmutil listlocalsnapshots / 2> /dev/null)
stop_section_spinner
[[ -z "$snapshot_list" ]] && return 0
# Parse and clean snapshots
local cleaned_count=0
local total_cleaned_size=0 # Estimation not possible without thin
local newest_ts=0
local newest_name=""
local -a snapshots=()
# Find the most recent snapshot to keep at least one version
while IFS= read -r line; do
# Format: com.apple.TimeMachine.2023-10-25-120000
if [[ "$line" =~ com\.apple\.TimeMachine\.([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{6}) ]]; then
local snap_name="${BASH_REMATCH[0]}"
snapshots+=("$snap_name")
local date_str="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]:0:2}:${BASH_REMATCH[4]:2:2}:${BASH_REMATCH[4]:4:2}"
local snap_ts=$(date -j -f "%Y-%m-%d %H:%M:%S" "$date_str" "+%s" 2> /dev/null || echo "0")
# Skip if parsing failed
[[ "$snap_ts" == "0" ]] && continue
if [[ "$snap_ts" -gt "$newest_ts" ]]; then
newest_ts="$snap_ts"
@@ -332,16 +306,13 @@ clean_local_snapshots() {
local snap_name
for snap_name in "${snapshots[@]}"; do
# Format: com.apple.TimeMachine.2023-10-25-120000
if [[ "$snap_name" =~ com\.apple\.TimeMachine\.([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{6}) ]]; then
# Remove all but the most recent snapshot
if [[ "${BASH_REMATCH[0]}" != "$newest_name" ]]; then
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Local snapshot: $snap_name ${YELLOW}dry-run${NC}"
((cleaned_count++))
note_activity
else
# Secure removal
if sudo tmutil deletelocalsnapshots "${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]}-${BASH_REMATCH[4]}" > /dev/null 2>&1; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed snapshot: $snap_name"
((cleaned_count++))

View File

@@ -62,7 +62,7 @@ scan_external_volumes() {
done
stop_section_spinner
}
# Clean Finder metadata (.DS_Store files)
# Finder metadata (.DS_Store).
clean_finder_metadata() {
stop_section_spinner
if [[ "$PROTECT_FINDER_METADATA" == "true" ]]; then
@@ -72,16 +72,14 @@ clean_finder_metadata() {
fi
clean_ds_store_tree "$HOME" "Home directory (.DS_Store)"
}
# Clean macOS system caches
# macOS system caches and user-level leftovers.
clean_macos_system_caches() {
stop_section_spinner
# Clean saved application states with protection for System Settings
# Note: safe_clean already calls should_protect_path for each file
# safe_clean already checks protected paths.
safe_clean ~/Library/Saved\ Application\ State/* "Saved application states" || true
safe_clean ~/Library/Caches/com.apple.photoanalysisd "Photo analysis cache" || true
safe_clean ~/Library/Caches/com.apple.akd "Apple ID cache" || true
safe_clean ~/Library/Caches/com.apple.WebKit.Networking/* "WebKit network cache" || true
# Extra user items
safe_clean ~/Library/DiagnosticReports/* "Diagnostic reports" || true
safe_clean ~/Library/Caches/com.apple.QuickLook.thumbnailcache "QuickLook thumbnails" || true
safe_clean ~/Library/Caches/Quick\ Look/* "QuickLook cache" || true
@@ -158,28 +156,26 @@ clean_mail_downloads() {
note_activity
fi
}
# Clean sandboxed app caches
# Sandboxed app caches.
clean_sandboxed_app_caches() {
stop_section_spinner
safe_clean ~/Library/Containers/com.apple.wallpaper.agent/Data/Library/Caches/* "Wallpaper agent cache"
safe_clean ~/Library/Containers/com.apple.mediaanalysisd/Data/Library/Caches/* "Media analysis cache"
safe_clean ~/Library/Containers/com.apple.AppStore/Data/Library/Caches/* "App Store cache"
safe_clean ~/Library/Containers/com.apple.configurator.xpc.InternetService/Data/tmp/* "Apple Configurator temp files"
# Clean sandboxed app caches - iterate quietly to avoid UI flashing
local containers_dir="$HOME/Library/Containers"
[[ ! -d "$containers_dir" ]] && return 0
start_section_spinner "Scanning sandboxed apps..."
local total_size=0
local cleaned_count=0
local found_any=false
# Enable nullglob for safe globbing; restore afterwards
# Use nullglob to avoid literal globs.
local _ng_state
_ng_state=$(shopt -p nullglob || true)
shopt -s nullglob
for container_dir in "$containers_dir"/*; do
process_container_cache "$container_dir"
done
# Restore nullglob to previous state
eval "$_ng_state"
stop_section_spinner
if [[ "$found_any" == "true" ]]; then
@@ -195,11 +191,10 @@ clean_sandboxed_app_caches() {
note_activity
fi
}
# Process a single container cache directory (reduces nesting)
# Process a single container cache directory.
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")
if is_critical_system_component "$bundle_id"; then
return 0
@@ -208,17 +203,15 @@ process_container_cache() {
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)
# Fast non-empty check.
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 with local nullglob management
# Clean contents safely with local nullglob.
local _ng_state
_ng_state=$(shopt -p nullglob || true)
shopt -s nullglob
@@ -230,11 +223,11 @@ process_container_cache() {
fi
fi
}
# Clean browser caches (Safari, Chrome, Edge, Firefox, etc.)
# Browser caches (Safari/Chrome/Edge/Firefox).
clean_browsers() {
stop_section_spinner
safe_clean ~/Library/Caches/com.apple.Safari/* "Safari cache"
# Chrome/Chromium
# Chrome/Chromium.
safe_clean ~/Library/Caches/Google/Chrome/* "Chrome cache"
safe_clean ~/Library/Application\ Support/Google/Chrome/*/Application\ Cache/* "Chrome app cache"
safe_clean ~/Library/Application\ Support/Google/Chrome/*/GPUCache/* "Chrome GPU cache"
@@ -251,7 +244,7 @@ clean_browsers() {
safe_clean ~/Library/Caches/zen/* "Zen cache"
safe_clean ~/Library/Application\ Support/Firefox/Profiles/*/cache2/* "Firefox profile cache"
}
# Clean cloud storage app caches
# Cloud storage caches.
clean_cloud_storage() {
stop_section_spinner
safe_clean ~/Library/Caches/com.dropbox.* "Dropbox cache"
@@ -262,7 +255,7 @@ clean_cloud_storage() {
safe_clean ~/Library/Caches/com.box.desktop "Box cache"
safe_clean ~/Library/Caches/com.microsoft.OneDrive "OneDrive cache"
}
# Clean office application caches
# Office app caches.
clean_office_applications() {
stop_section_spinner
safe_clean ~/Library/Caches/com.microsoft.Word "Microsoft Word cache"
@@ -274,7 +267,7 @@ clean_office_applications() {
safe_clean ~/Library/Caches/org.mozilla.thunderbird/* "Thunderbird cache"
safe_clean ~/Library/Caches/com.apple.mail/* "Apple Mail cache"
}
# Clean virtualization tools
# Virtualization caches.
clean_virtualization_tools() {
stop_section_spinner
safe_clean ~/Library/Caches/com.vmware.fusion "VMware Fusion cache"
@@ -282,7 +275,7 @@ clean_virtualization_tools() {
safe_clean ~/VirtualBox\ VMs/.cache "VirtualBox cache"
safe_clean ~/.vagrant.d/tmp/* "Vagrant temporary files"
}
# Clean Application Support logs and caches
# Application Support logs/caches.
clean_application_support_logs() {
stop_section_spinner
if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" > /dev/null 2>&1; then
@@ -294,11 +287,10 @@ clean_application_support_logs() {
local total_size=0
local cleaned_count=0
local found_any=false
# Enable nullglob for safe globbing
# Enable nullglob for safe globbing.
local _ng_state
_ng_state=$(shopt -p nullglob || true)
shopt -s nullglob
# Clean log directories and cache patterns
for app_dir in ~/Library/Application\ Support/*; do
[[ -d "$app_dir" ]] || continue
local app_name=$(basename "$app_dir")
@@ -333,7 +325,7 @@ clean_application_support_logs() {
fi
done
done
# Clean Group Containers logs
# Group Containers logs (explicit allowlist).
local known_group_containers=(
"group.com.apple.contentdelivery"
)
@@ -357,7 +349,6 @@ clean_application_support_logs() {
fi
done
done
# Restore nullglob to previous state
eval "$_ng_state"
stop_section_spinner
if [[ "$found_any" == "true" ]]; then
@@ -373,10 +364,10 @@ clean_application_support_logs() {
note_activity
fi
}
# Check and show iOS device backup info
# iOS device backup info.
check_ios_device_backups() {
local backup_dir="$HOME/Library/Application Support/MobileSync/Backup"
# Simplified check without find to avoid hanging
# Simplified check without find to avoid hanging.
if [[ -d "$backup_dir" ]]; then
local backup_kb=$(get_path_size_kb "$backup_dir")
if [[ -n "${backup_kb:-}" && "$backup_kb" -gt 102400 ]]; then
@@ -390,8 +381,7 @@ check_ios_device_backups() {
fi
return 0
}
# Env: IS_M_SERIES
# Clean Apple Silicon specific caches
# Apple Silicon specific caches (IS_M_SERIES).
clean_apple_silicon_caches() {
if [[ "${IS_M_SERIES:-false}" != "true" ]]; then
return 0

View File

@@ -12,9 +12,7 @@ readonly MOLE_APP_PROTECTION_LOADED=1
_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[[ -z "${MOLE_BASE_LOADED:-}" ]] && source "$_MOLE_CORE_DIR/base.sh"
# ============================================================================
# Application Management
# ============================================================================
# Critical system components protected from uninstallation
readonly SYSTEM_CRITICAL_BUNDLES=(
@@ -70,9 +68,7 @@ readonly SYSTEM_CRITICAL_BUNDLES=(
# Applications with sensitive data; protected during cleanup but removable
readonly DATA_PROTECTED_BUNDLES=(
# ============================================================================
# System Utilities & Cleanup Tools
# ============================================================================
"com.nektony.*" # App Cleaner & Uninstaller
"com.macpaw.*" # CleanMyMac, CleanMaster
"com.freemacsoft.AppCleaner" # AppCleaner
@@ -82,9 +78,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.grandperspectiv.*" # GrandPerspective
"com.binaryfruit.*" # FusionCast
# ============================================================================
# Password Managers & Security
# ============================================================================
"com.1password.*" # 1Password
"com.agilebits.*" # 1Password legacy
"com.lastpass.*" # LastPass
@@ -95,9 +89,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.authy.*" # Authy
"com.yubico.*" # YubiKey Manager
# ============================================================================
# Development Tools - IDEs & Editors
# ============================================================================
"com.jetbrains.*" # JetBrains IDEs (IntelliJ, DataGrip, etc.)
"JetBrains*" # JetBrains Application Support folders
"com.microsoft.VSCode" # Visual Studio Code
@@ -112,9 +104,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"abnerworks.Typora" # Typora (Markdown editor)
"com.uranusjr.macdown" # MacDown
# ============================================================================
# AI & LLM Tools
# ============================================================================
"com.todesktop.*" # Cursor (often uses generic todesktop ID)
"Cursor" # Cursor App Support
"com.anthropic.claude*" # Claude
@@ -136,9 +126,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.quora.poe.electron" # Poe
"chat.openai.com.*" # OpenAI web wrappers
# ============================================================================
# Development Tools - Database Clients
# ============================================================================
"com.sequelpro.*" # Sequel Pro
"com.sequel-ace.*" # Sequel Ace
"com.tinyapp.*" # TablePlus
@@ -151,9 +139,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.valentina-db.Valentina-Studio" # Valentina Studio
"com.dbvis.DbVisualizer" # DbVisualizer
# ============================================================================
# Development Tools - API & Network
# ============================================================================
"com.postmanlabs.mac" # Postman
"com.konghq.insomnia" # Insomnia
"com.CharlesProxy.*" # Charles Proxy
@@ -164,9 +150,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.telerik.Fiddler" # Fiddler
"com.usebruno.app" # Bruno (API client)
# ============================================================================
# Network Proxy & VPN Tools (pattern-based protection)
# ============================================================================
# Clash variants
"*clash*" # All Clash variants (ClashX, ClashX Pro, Clash Verge, etc)
"*Clash*" # Capitalized variants
@@ -217,9 +201,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"*Fliqlo*" # Fliqlo screensaver (all case variants)
"*fliqlo*" # Fliqlo lowercase
# ============================================================================
# Development Tools - Git & Version Control
# ============================================================================
"com.github.GitHubDesktop" # GitHub Desktop
"com.sublimemerge" # Sublime Merge
"com.torusknot.SourceTreeNotMAS" # SourceTree
@@ -229,9 +211,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.fork.Fork" # Fork
"com.axosoft.gitkraken" # GitKraken
# ============================================================================
# Development Tools - Terminal & Shell
# ============================================================================
"com.googlecode.iterm2" # iTerm2
"net.kovidgoyal.kitty" # Kitty
"io.alacritty" # Alacritty
@@ -242,9 +222,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"dev.warp.Warp-Stable" # Warp
"com.termius-dmg" # Termius (SSH client)
# ============================================================================
# Development Tools - Docker & Virtualization
# ============================================================================
"com.docker.docker" # Docker Desktop
"com.getutm.UTM" # UTM
"com.vmware.fusion" # VMware Fusion
@@ -253,9 +231,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.vagrant.*" # Vagrant
"com.orbstack.OrbStack" # OrbStack
# ============================================================================
# System Monitoring & Performance
# ============================================================================
"com.bjango.istatmenus*" # iStat Menus
"eu.exelban.Stats" # Stats
"com.monitorcontrol.*" # MonitorControl
@@ -264,9 +240,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.activity-indicator.app" # Activity Indicator
"net.cindori.sensei" # Sensei
# ============================================================================
# Window Management & Productivity
# ============================================================================
"com.macitbetter.*" # BetterTouchTool, BetterSnapTool
"com.hegenberg.*" # BetterTouchTool legacy
"com.manytricks.*" # Moom, Witch, Name Mangler, Resolutionator
@@ -284,9 +258,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.gaosun.eul" # eul (system monitor)
"com.pointum.hazeover" # HazeOver
# ============================================================================
# Launcher & Automation
# ============================================================================
"com.runningwithcrayons.Alfred" # Alfred
"com.raycast.macos" # Raycast
"com.blacktree.Quicksilver" # Quicksilver
@@ -297,9 +269,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"org.pqrs.Karabiner-Elements" # Karabiner-Elements
"com.apple.Automator" # Automator (system, but keep user workflows)
# ============================================================================
# Note-Taking & Documentation
# ============================================================================
"com.bear-writer.*" # Bear
"com.typora.*" # Typora
"com.ulyssesapp.*" # Ulysses
@@ -318,9 +288,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.reflect.ReflectApp" # Reflect
"com.inkdrop.*" # Inkdrop
# ============================================================================
# Design & Creative Tools
# ============================================================================
"com.adobe.*" # Adobe Creative Suite
"com.bohemiancoding.*" # Sketch
"com.figma.*" # Figma
@@ -338,9 +306,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.autodesk.*" # Autodesk products
"com.sketchup.*" # SketchUp
# ============================================================================
# Communication & Collaboration
# ============================================================================
"com.tencent.xinWeChat" # WeChat (Chinese users)
"com.tencent.qq" # QQ
"com.alibaba.DingTalkMac" # DingTalk
@@ -363,9 +329,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.postbox-inc.postbox" # Postbox
"com.tinyspeck.slackmacgap" # Slack legacy
# ============================================================================
# Task Management & Productivity
# ============================================================================
"com.omnigroup.OmniFocus*" # OmniFocus
"com.culturedcode.*" # Things
"com.todoist.*" # Todoist
@@ -380,9 +344,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.notion.id" # Notion (also note-taking)
"com.linear.linear" # Linear
# ============================================================================
# File Transfer & Sync
# ============================================================================
"com.panic.transmit*" # Transmit (FTP/SFTP)
"com.binarynights.ForkLift*" # ForkLift
"com.noodlesoft.Hazel" # Hazel
@@ -391,9 +353,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.apple.Xcode.CloudDocuments" # Xcode Cloud Documents
"com.synology.*" # Synology apps
# ============================================================================
# Cloud Storage & Backup (Issue #204)
# ============================================================================
"com.dropbox.*" # Dropbox
"com.getdropbox.*" # Dropbox legacy
"*dropbox*" # Dropbox helpers/updaters
@@ -420,9 +380,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.shirtpocket.*" # SuperDuper backup
"homebrew.mxcl.*" # Homebrew services
# ============================================================================
# Screenshot & Recording
# ============================================================================
"com.cleanshot.*" # CleanShot X
"com.xnipapp.xnip" # Xnip
"com.reincubate.camo" # Camo
@@ -436,9 +394,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.linebreak.CloudApp" # CloudApp
"com.droplr.droplr-mac" # Droplr
# ============================================================================
# Media & Entertainment
# ============================================================================
"com.spotify.client" # Spotify
"com.apple.Music" # Apple Music
"com.apple.podcasts" # Apple Podcasts
@@ -456,9 +412,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"tv.plex.player.desktop" # Plex
"com.netease.163music" # NetEase Music
# ============================================================================
# License Management & App Stores
# ============================================================================
"com.paddle.Paddle*" # Paddle (license management)
"com.setapp.DesktopClient" # Setapp
"com.devmate.*" # DevMate (license framework)

View File

@@ -1,26 +1,19 @@
#!/bin/bash
# System Configuration Maintenance Module
# Fix broken preferences and broken login items
# System Configuration Maintenance Module.
# Fix broken preferences and login items.
set -euo pipefail
# ============================================================================
# Broken Preferences Detection and Cleanup
# Find and remove corrupted .plist files
# ============================================================================
# Clean corrupted preference files
# Remove corrupted preference files.
fix_broken_preferences() {
local prefs_dir="$HOME/Library/Preferences"
[[ -d "$prefs_dir" ]] || return 0
local broken_count=0
# Check main preferences directory
while IFS= read -r plist_file; do
[[ -f "$plist_file" ]] || continue
# Skip system preferences
local filename
filename=$(basename "$plist_file")
case "$filename" in
@@ -29,15 +22,13 @@ fix_broken_preferences() {
;;
esac
# Validate plist using plutil
plutil -lint "$plist_file" > /dev/null 2>&1 && continue
# Remove broken plist
safe_remove "$plist_file" true > /dev/null 2>&1 || true
((broken_count++))
done < <(command find "$prefs_dir" -maxdepth 1 -name "*.plist" -type f 2> /dev/null || true)
# Check ByHost preferences with timeout protection
# Check ByHost preferences.
local byhost_dir="$prefs_dir/ByHost"
if [[ -d "$byhost_dir" ]]; then
while IFS= read -r plist_file; do

View File

@@ -3,16 +3,12 @@
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)
# Config constants (override via env).
readonly MOLE_TM_THIN_TIMEOUT=180
readonly MOLE_TM_THIN_VALUE=9999999999
readonly MOLE_SQLITE_MAX_SIZE=104857600 # 100MB
# Helper function to get appropriate icon and color for dry-run mode
# Dry-run aware output.
opt_msg() {
local message="$1"
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
@@ -92,7 +88,6 @@ is_memory_pressure_high() {
}
flush_dns_cache() {
# Skip actual flush in dry-run mode
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
MOLE_DNS_FLUSHED=1
return 0
@@ -105,7 +100,7 @@ flush_dns_cache() {
return 1
}
# Rebuild databases and flush caches
# Basic system maintenance.
opt_system_maintenance() {
if flush_dns_cache; then
opt_msg "DNS cache flushed"
@@ -120,10 +115,8 @@ opt_system_maintenance() {
fi
}
# Refresh Finder caches (QuickLook and icon services)
# Note: Safari caches are cleaned separately in clean/user.sh, so excluded here
# Refresh Finder caches (QuickLook/icon services).
opt_cache_refresh() {
# Skip qlmanage commands in dry-run mode
if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
qlmanage -r cache > /dev/null 2>&1 || true
qlmanage -r > /dev/null 2>&1 || true
@@ -151,7 +144,7 @@ opt_cache_refresh() {
# Removed: opt_radio_refresh - Interrupts active user connections (WiFi, Bluetooth), degrading UX
# Saved state: remove OLD app saved states (7+ days)
# Old saved states cleanup.
opt_saved_state_cleanup() {
local state_dir="$HOME/Library/Saved Application State"
@@ -193,7 +186,7 @@ opt_fix_broken_configs() {
fi
}
# Network cache optimization
# DNS cache refresh.
opt_network_optimization() {
if [[ "${MOLE_DNS_FLUSHED:-0}" == "1" ]]; then
opt_msg "DNS cache already refreshed"
@@ -209,8 +202,7 @@ opt_network_optimization() {
fi
}
# SQLite database vacuum optimization
# Compresses and optimizes SQLite databases for Mail, Messages, Safari
# SQLite vacuum for Mail/Messages/Safari (safety checks applied).
opt_sqlite_vacuum() {
if ! command -v sqlite3 > /dev/null 2>&1; then
echo -e " ${GRAY}-${NC} Database optimization already optimal (sqlite3 unavailable)"
@@ -254,15 +246,13 @@ opt_sqlite_vacuum() {
[[ ! -f "$db_file" ]] && continue
[[ "$db_file" == *"-wal" || "$db_file" == *"-shm" ]] && continue
# Skip if protected
should_protect_path "$db_file" && continue
# Verify it's a SQLite database
if ! file "$db_file" 2> /dev/null | grep -q "SQLite"; then
continue
fi
# Safety check 1: Skip large databases (>100MB) to avoid timeouts
# Skip large DBs (>100MB).
local file_size
file_size=$(get_file_size "$db_file")
if [[ "$file_size" -gt "$MOLE_SQLITE_MAX_SIZE" ]]; then
@@ -270,7 +260,7 @@ opt_sqlite_vacuum() {
continue
fi
# Safety check 2: Skip if freelist is tiny (already compact)
# Skip if freelist is tiny (already compact).
local page_info=""
page_info=$(run_with_timeout 5 sqlite3 "$db_file" "PRAGMA page_count; PRAGMA freelist_count;" 2> /dev/null || echo "")
local page_count=""
@@ -284,7 +274,7 @@ opt_sqlite_vacuum() {
fi
fi
# Safety check 3: Verify database integrity before VACUUM
# Verify integrity before VACUUM.
if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
local integrity_check=""
set +e
@@ -292,14 +282,12 @@ opt_sqlite_vacuum() {
local integrity_status=$?
set -e
# Skip if integrity check failed or database is corrupted
if [[ $integrity_status -ne 0 ]] || ! echo "$integrity_check" | grep -q "ok"; then
((skipped++))
continue
fi
fi
# Try to vacuum (skip in dry-run mode)
local exit_code=0
if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
set +e
@@ -315,7 +303,6 @@ opt_sqlite_vacuum() {
((failed++))
fi
else
# In dry-run mode, just count the database
((vacuumed++))
fi
done < <(compgen -G "$pattern" || true)
@@ -346,8 +333,7 @@ opt_sqlite_vacuum() {
fi
}
# LaunchServices database rebuild
# Fixes "Open with" menu issues, duplicate apps, broken file associations
# LaunchServices rebuild ("Open with" issues).
opt_launch_services_rebuild() {
if [[ -t 1 ]]; then
start_inline_spinner ""
@@ -358,7 +344,6 @@ opt_launch_services_rebuild() {
if [[ -f "$lsregister" ]]; then
local success=0
# Skip actual rebuild in dry-run mode
if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
set +e
"$lsregister" -r -domain local -domain user -domain system > /dev/null 2>&1
@@ -369,7 +354,7 @@ opt_launch_services_rebuild() {
fi
set -e
else
success=0 # Assume success in dry-run mode
success=0
fi
if [[ -t 1 ]]; then
@@ -390,18 +375,16 @@ opt_launch_services_rebuild() {
fi
}
# Font cache rebuild
# Fixes font rendering issues, missing fonts, and character display problems
# Font cache rebuild.
opt_font_cache_rebuild() {
local success=false
# Skip actual font cache removal in dry-run mode
if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
if sudo atsutil databases -remove > /dev/null 2>&1; then
success=true
fi
else
success=true # Assume success in dry-run mode
success=true
fi
if [[ "$success" == "true" ]]; then
@@ -417,8 +400,7 @@ opt_font_cache_rebuild() {
# - opt_dyld_cache_update: Low benefit, time-consuming, auto-managed by macOS
# - opt_system_services_refresh: Risk of data loss when killing system services
# Memory pressure relief
# Clears inactive memory and disk cache to improve system responsiveness
# Memory pressure relief.
opt_memory_pressure_relief() {
if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
if ! is_memory_pressure_high; then
@@ -438,8 +420,7 @@ opt_memory_pressure_relief() {
fi
}
# Network stack optimization
# Flushes routing table and ARP cache to resolve network issues
# Network stack reset (route + ARP).
opt_network_stack_optimize() {
local route_flushed="false"
local arp_flushed="false"
@@ -460,12 +441,10 @@ opt_network_stack_optimize() {
return 0
fi
# Flush routing table
if sudo route -n flush > /dev/null 2>&1; then
route_flushed="true"
fi
# Clear ARP cache
if sudo arp -a -d > /dev/null 2>&1; then
arp_flushed="true"
fi
@@ -487,8 +466,7 @@ opt_network_stack_optimize() {
fi
}
# Disk permissions repair
# Fixes user home directory permission issues
# User directory permissions repair.
opt_disk_permissions_repair() {
local user_id
user_id=$(id -u)
@@ -524,11 +502,7 @@ opt_disk_permissions_repair() {
fi
}
# Bluetooth module reset
# Resets Bluetooth daemon to fix connectivity issues
# Intelligently detects Bluetooth audio usage:
# 1. Checks if default audio output is Bluetooth (precise)
# 2. Falls back to Bluetooth + media app detection (compatibility)
# Bluetooth reset (skip if HID/audio active).
opt_bluetooth_reset() {
local spinner_started="false"
if [[ -t 1 ]]; then
@@ -545,26 +519,20 @@ opt_bluetooth_reset() {
return 0
fi
# Check if any audio is playing through Bluetooth
local bt_audio_active=false
# Method 1: Check if default audio output is Bluetooth (precise)
local audio_info
audio_info=$(system_profiler SPAudioDataType 2> /dev/null || echo "")
# Extract default output device information
local default_output
default_output=$(echo "$audio_info" | awk '/Default Output Device: Yes/,/^$/' 2> /dev/null || echo "")
# Check if transport type is Bluetooth
if echo "$default_output" | grep -qi "Transport:.*Bluetooth"; then
bt_audio_active=true
fi
# Method 2: Fallback - Bluetooth connected + media apps running (compatibility)
if [[ "$bt_audio_active" == "false" ]]; then
if system_profiler SPBluetoothDataType 2> /dev/null | grep -q "Connected: Yes"; then
# Extended media apps list for broader coverage
local -a media_apps=("Music" "Spotify" "VLC" "QuickTime Player" "TV" "Podcasts" "Safari" "Google Chrome" "Chrome" "Firefox" "Arc" "IINA" "mpv")
for app in "${media_apps[@]}"; do
if pgrep -x "$app" > /dev/null 2>&1; then
@@ -583,7 +551,6 @@ opt_bluetooth_reset() {
return 0
fi
# Safe to reset Bluetooth
if sudo pkill -TERM bluetoothd > /dev/null 2>&1; then
sleep 1
if pgrep -x bluetoothd > /dev/null 2>&1; then
@@ -609,11 +576,8 @@ opt_bluetooth_reset() {
fi
}
# Spotlight index optimization
# Rebuilds Spotlight index if search is slow or results are inaccurate
# Only runs if index is actually problematic
# Spotlight index check/rebuild (only if slow).
opt_spotlight_index_optimize() {
# Check if Spotlight indexing is disabled
local spotlight_status
spotlight_status=$(mdutil -s / 2> /dev/null || echo "")
@@ -622,9 +586,7 @@ opt_spotlight_index_optimize() {
return 0
fi
# Check if indexing is currently running
if echo "$spotlight_status" | grep -qi "Indexing enabled" && ! echo "$spotlight_status" | grep -qi "Indexing and searching disabled"; then
# Check index health by testing search speed twice
local slow_count=0
local test_start test_end test_duration
for _ in 1 2; do
@@ -663,13 +625,11 @@ opt_spotlight_index_optimize() {
fi
}
# Dock cache refresh
# Fixes broken icons, duplicate items, and visual glitches in the Dock
# Dock cache refresh.
opt_dock_refresh() {
local dock_support="$HOME/Library/Application Support/Dock"
local refreshed=false
# Remove Dock database files (icons, positions, etc.)
if [[ -d "$dock_support" ]]; then
while IFS= read -r db_file; do
if [[ -f "$db_file" ]]; then
@@ -678,14 +638,11 @@ opt_dock_refresh() {
done < <(find "$dock_support" -name "*.db" -type f 2> /dev/null || true)
fi
# Also clear Dock plist cache
local dock_plist="$HOME/Library/Preferences/com.apple.dock.plist"
if [[ -f "$dock_plist" ]]; then
# Just touch to invalidate cache, don't delete (preserves user settings)
touch "$dock_plist" 2> /dev/null || true
fi
# Restart Dock to apply changes (skip in dry-run mode)
if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
killall Dock 2> /dev/null || true
fi
@@ -696,7 +653,7 @@ opt_dock_refresh() {
opt_msg "Dock refreshed"
}
# Execute optimization by action name
# Dispatch optimization by action name.
execute_optimization() {
local action="$1"
local path="${2:-}"

View File

@@ -2,18 +2,13 @@
set -euo pipefail
# Ensure common.sh is loaded
# Ensure common.sh is loaded.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
[[ -z "${MOLE_COMMON_LOADED:-}" ]] && source "$SCRIPT_DIR/lib/core/common.sh"
# Batch uninstall functionality with minimal confirmations
# Replaces the overly verbose individual confirmation approach
# Batch uninstall with a single confirmation.
# ============================================================================
# Configuration: User Data Detection Patterns
# ============================================================================
# Directories that typically contain user-customized configurations, themes,
# or personal data that users might want to backup before uninstalling
# User data detection patterns (prompt user to backup if found).
readonly SENSITIVE_DATA_PATTERNS=(
"\.warp" # Warp terminal configs/themes
"/\.config/" # Standard Unix config directory
@@ -26,24 +21,20 @@ readonly SENSITIVE_DATA_PATTERNS=(
"/\.gnupg/" # GPG keys (critical)
)
# Join patterns into a single regex for grep
# Join patterns into a single regex for grep.
SENSITIVE_DATA_REGEX=$(
IFS='|'
echo "${SENSITIVE_DATA_PATTERNS[*]}"
)
# Decode and validate base64 encoded file list
# Returns decoded string if valid, empty string otherwise
# Decode and validate base64 file list (safe for set -e).
decode_file_list() {
local encoded="$1"
local app_name="$2"
local decoded
# Decode base64 data (macOS uses -D, GNU uses -d)
# Try macOS format first, then GNU format for compatibility
# IMPORTANT: Always return 0 to prevent set -e from terminating the script
# macOS uses -D, GNU uses -d. Always return 0 for set -e safety.
if ! decoded=$(printf '%s' "$encoded" | base64 -D 2> /dev/null); then
# Fallback to GNU base64 format
if ! decoded=$(printf '%s' "$encoded" | base64 -d 2> /dev/null); then
log_error "Failed to decode file list for $app_name" >&2
echo ""
@@ -51,14 +42,12 @@ decode_file_list() {
fi
fi
# Validate decoded data doesn't contain null bytes
if [[ "$decoded" =~ $'\0' ]]; then
log_warning "File list for $app_name contains null bytes, rejecting" >&2
echo ""
return 0 # Return success with empty string
fi
# Validate paths look reasonable (each line should be a path or empty)
while IFS= read -r line; do
if [[ -n "$line" && ! "$line" =~ ^/ ]]; then
log_warning "Invalid path in file list for $app_name: $line" >&2
@@ -70,24 +59,21 @@ decode_file_list() {
echo "$decoded"
return 0
}
# Note: find_app_files() and calculate_total_size() functions now in lib/core/common.sh
# Note: find_app_files() and calculate_total_size() are in lib/core/common.sh.
# Stop Launch Agents and Daemons for an app
# Args: $1 = bundle_id, $2 = has_system_files (true/false)
# Stop Launch Agents/Daemons for an app.
stop_launch_services() {
local bundle_id="$1"
local has_system_files="${2:-false}"
[[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0
# User-level Launch Agents
if [[ -d ~/Library/LaunchAgents ]]; then
while IFS= read -r -d '' plist; do
launchctl unload "$plist" 2> /dev/null || true
done < <(find ~/Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null)
fi
# System-level services (requires sudo)
if [[ "$has_system_files" == "true" ]]; then
if [[ -d /Library/LaunchAgents ]]; then
while IFS= read -r -d '' plist; do
@@ -102,9 +88,7 @@ stop_launch_services() {
fi
}
# Remove a list of files (handles both regular files and symlinks)
# Args: $1 = file_list (newline-separated), $2 = use_sudo (true/false)
# Returns: number of files removed
# Remove files (handles symlinks, optional sudo).
remove_file_list() {
local file_list="$1"
local use_sudo="${2:-false}"
@@ -114,14 +98,12 @@ remove_file_list() {
[[ -n "$file" && -e "$file" ]] || continue
if [[ -L "$file" ]]; then
# Symlink: use direct rm
if [[ "$use_sudo" == "true" ]]; then
sudo rm "$file" 2> /dev/null && ((count++)) || true
else
rm "$file" 2> /dev/null && ((count++)) || true
fi
else
# Regular file/directory: use safe_remove
if [[ "$use_sudo" == "true" ]]; then
safe_sudo_remove "$file" && ((count++)) || true
else
@@ -133,8 +115,7 @@ remove_file_list() {
echo "$count"
}
# Batch uninstall with single confirmation
# Globals: selected_apps (read) - array of selected applications
# Batch uninstall with single confirmation.
batch_uninstall_applications() {
local total_size_freed=0
@@ -144,19 +125,18 @@ batch_uninstall_applications() {
return 0
fi
# Pre-process: Check for running apps and calculate total impact
# Pre-scan: running apps, sudo needs, size.
local -a running_apps=()
local -a sudo_apps=()
local total_estimated_size=0
local -a app_details=()
# Analyze selected apps with progress indicator
if [[ -t 1 ]]; then start_inline_spinner "Scanning files..."; fi
for selected_app in "${selected_apps[@]}"; do
[[ -z "$selected_app" ]] && continue
IFS='|' read -r _ app_path app_name bundle_id _ _ <<< "$selected_app"
# Check if app is running using executable name from bundle
# Check running app by bundle executable if available.
local exec_name=""
if [[ -e "$app_path/Contents/Info.plist" ]]; then
exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "")
@@ -166,11 +146,7 @@ batch_uninstall_applications() {
running_apps+=("$app_name")
fi
# Check if app requires sudo to delete (either app bundle or system files)
# Need sudo if:
# 1. Parent directory is not writable (may be owned by another user or root)
# 2. App owner is root
# 3. App owner is different from current user
# Sudo needed if bundle owner/dir is not writable or system files exist.
local needs_sudo=false
local app_owner=$(get_file_owner "$app_path")
local current_user=$(whoami)
@@ -180,11 +156,11 @@ batch_uninstall_applications() {
needs_sudo=true
fi
# Calculate size for summary (including system files)
# Size estimate includes related and system files.
local app_size_kb=$(get_path_size_kb "$app_path")
local related_files=$(find_app_files "$bundle_id" "$app_name")
local related_size_kb=$(calculate_total_size "$related_files")
# system_files is a newline-separated string, not an array
# system_files is a newline-separated string, not an array.
# shellcheck disable=SC2178,SC2128
local system_files=$(find_app_system_files "$bundle_id" "$app_name")
# shellcheck disable=SC2128
@@ -192,7 +168,6 @@ batch_uninstall_applications() {
local total_kb=$((app_size_kb + related_size_kb + system_size_kb))
((total_estimated_size += total_kb))
# Check if system files require sudo
# shellcheck disable=SC2128
if [[ -n "$system_files" ]]; then
needs_sudo=true
@@ -202,33 +177,28 @@ batch_uninstall_applications() {
sudo_apps+=("$app_name")
fi
# Check for sensitive user data (performance optimization: do this once)
# Check for sensitive user data once.
local has_sensitive_data="false"
if [[ -n "$related_files" ]] && echo "$related_files" | grep -qE "$SENSITIVE_DATA_REGEX"; then
has_sensitive_data="true"
fi
# Store details for later use
# Base64 encode file lists to handle multi-line data safely (single line)
# Store details for later use (base64 keeps lists on one line).
local encoded_files
encoded_files=$(printf '%s' "$related_files" | base64 | tr -d '\n')
local encoded_system_files
encoded_system_files=$(printf '%s' "$system_files" | base64 | tr -d '\n')
# Store needs_sudo to avoid recalculating during deletion phase
app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$encoded_files|$encoded_system_files|$has_sensitive_data|$needs_sudo")
done
if [[ -t 1 ]]; then stop_inline_spinner; fi
# Format size display (convert KB to bytes for bytes_to_human())
local size_display=$(bytes_to_human "$((total_estimated_size * 1024))")
# Display detailed file list for each app before confirmation
echo ""
echo -e "${PURPLE_BOLD}Files to be removed:${NC}"
echo ""
# Check for apps with user data that might need backup
# Performance optimization: use pre-calculated flags from app_details
# Warn if user data is detected.
local has_user_data=false
for detail in "${app_details[@]}"; do
IFS='|' read -r _ _ _ _ _ _ has_sensitive_data <<< "$detail"
@@ -252,7 +222,7 @@ batch_uninstall_applications() {
echo -e "${BLUE}${ICON_CONFIRM}${NC} ${app_name} ${GRAY}(${app_size_display})${NC}"
echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${app_path/$HOME/~}"
# Show related files (limit to 5 most important ones for brevity)
# Show related files (limit to 5).
local file_count=0
local max_files=5
while IFS= read -r file; do
@@ -264,7 +234,7 @@ batch_uninstall_applications() {
fi
done <<< "$related_files"
# Show system files
# Show system files (limit to 5).
local sys_file_count=0
while IFS= read -r file; do
if [[ -n "$file" && -e "$file" ]]; then
@@ -275,7 +245,6 @@ batch_uninstall_applications() {
fi
done <<< "$system_files"
# Show count of remaining files if truncated
local total_hidden=$((file_count > max_files ? file_count - max_files : 0))
((total_hidden += sys_file_count > max_files ? sys_file_count - max_files : 0))
if [[ $total_hidden -gt 0 ]]; then
@@ -283,7 +252,7 @@ batch_uninstall_applications() {
fi
done
# Show summary and get batch confirmation first (before asking for password)
# Confirmation before requesting sudo.
local app_total=${#selected_apps[@]}
local app_text="app"
[[ $app_total -gt 1 ]] && app_text="apps"
@@ -315,9 +284,8 @@ batch_uninstall_applications() {
;;
esac
# User confirmed, now request sudo access if needed
# Request sudo if needed.
if [[ ${#sudo_apps[@]} -gt 0 ]]; then
# Check if sudo is already cached
if ! sudo -n true 2> /dev/null; then
if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then
echo ""
@@ -325,10 +293,9 @@ batch_uninstall_applications() {
return 1
fi
fi
# Start sudo keepalive with robust parent checking
# Keep sudo alive during uninstall.
parent_pid=$$
(while true; do
# Check if parent process still exists first
if ! kill -0 "$parent_pid" 2> /dev/null; then
exit 0
fi
@@ -340,10 +307,7 @@ batch_uninstall_applications() {
if [[ -t 1 ]]; then start_inline_spinner "Uninstalling apps..."; fi
# Force quit running apps first (batch)
# Note: Apps are already killed in the individual uninstall loop below with app_path for precise matching
# Perform uninstallations (silent mode, show results at end)
# Perform uninstallations (silent mode, show results at end).
if [[ -t 1 ]]; then stop_inline_spinner; fi
local success_count=0 failed_count=0
local -a failed_items=()
@@ -354,23 +318,19 @@ batch_uninstall_applications() {
local system_files=$(decode_file_list "$encoded_system_files" "$app_name")
local reason=""
# Note: needs_sudo is already calculated during scanning phase (performance optimization)
# Stop Launch Agents and Daemons before removal
# Stop Launch Agents/Daemons before removal.
local has_system_files="false"
[[ -n "$system_files" ]] && has_system_files="true"
stop_launch_services "$bundle_id" "$has_system_files"
# Force quit app if still running
if ! force_kill_app "$app_name" "$app_path"; then
reason="still running"
fi
# Remove the application only if not running
# Remove the application only if not running.
if [[ -z "$reason" ]]; then
if [[ "$needs_sudo" == true ]]; then
if ! safe_sudo_remove "$app_path"; then
# Determine specific failure reason (only fetch owner info when needed)
local app_owner=$(get_file_owner "$app_path")
local current_user=$(whoami)
if [[ -n "$app_owner" && "$app_owner" != "$current_user" && "$app_owner" != "root" ]]; then
@@ -384,25 +344,18 @@ batch_uninstall_applications() {
fi
fi
# Remove related files if app removal succeeded
# Remove related files if app removal succeeded.
if [[ -z "$reason" ]]; then
# Remove user-level files
remove_file_list "$related_files" "false" > /dev/null
# Remove system-level files (requires sudo)
remove_file_list "$system_files" "true" > /dev/null
# Clean up macOS defaults (preference domain)
# This removes configuration data stored in the macOS defaults system
# Note: This complements plist file deletion by clearing cached preferences
# Clean up macOS defaults (preference domains).
if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then
# 1. Standard defaults domain cleanup
if defaults read "$bundle_id" &> /dev/null; then
defaults delete "$bundle_id" 2> /dev/null || true
fi
# 2. Clean up ByHost preferences (machine-specific configs)
# These are often missed by standard cleanup tools
# Format: ~/Library/Preferences/ByHost/com.app.id.XXXX.plist
# ByHost preferences (machine-specific).
if [[ -d ~/Library/Preferences/ByHost ]]; then
find ~/Library/Preferences/ByHost -maxdepth 1 -name "${bundle_id}.*.plist" -delete 2> /dev/null || true
fi
@@ -435,7 +388,7 @@ batch_uninstall_applications() {
success_line+=", freed ${GREEN}${freed_display}${NC}"
fi
# Format app list with max 3 per line
# Format app list with max 3 per line.
if [[ -n "$success_list" ]]; then
local idx=0
local is_first_line=true
@@ -445,25 +398,20 @@ batch_uninstall_applications() {
local display_item="${GREEN}${app_name}${NC}"
if ((idx % 3 == 0)); then
# Start new line
if [[ -n "$current_line" ]]; then
summary_details+=("$current_line")
fi
if [[ "$is_first_line" == true ]]; then
# First line: append to success_line
current_line="${success_line}: $display_item"
is_first_line=false
else
# Subsequent lines: just the apps
current_line="$display_item"
fi
else
# Add to current line
current_line="$current_line, $display_item"
fi
((idx++))
done
# Add the last line
if [[ -n "$current_line" ]]; then
summary_details+=("$current_line")
fi
@@ -509,12 +457,11 @@ batch_uninstall_applications() {
print_summary_block "$title" "${summary_details[@]}"
printf '\n'
# Clean up Dock entries for uninstalled apps
# Clean up Dock entries for uninstalled apps.
if [[ $success_count -gt 0 ]]; then
local -a removed_paths=()
for detail in "${app_details[@]}"; do
IFS='|' read -r app_name app_path _ _ _ _ <<< "$detail"
# Check if this app was successfully removed
for success_name in "${success_items[@]}"; do
if [[ "$success_name" == "$app_name" ]]; then
removed_paths+=("$app_path")
@@ -527,14 +474,14 @@ batch_uninstall_applications() {
fi
fi
# Clean up sudo keepalive if it was started
# Clean up sudo keepalive if it was started.
if [[ -n "${sudo_keepalive_pid:-}" ]]; then
kill "$sudo_keepalive_pid" 2> /dev/null || true
wait "$sudo_keepalive_pid" 2> /dev/null || true
sudo_keepalive_pid=""
fi
# Invalidate cache if any apps were successfully uninstalled
# Invalidate cache if any apps were successfully uninstalled.
if [[ $success_count -gt 0 ]]; then
local cache_file="$HOME/.cache/mole/app_scan_cache"
rm -f "$cache_file" 2> /dev/null || true