mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 15:39:42 +00:00
- Fix safe_remove set -e trap in command substitution - Fix has_full_disk_access false positives and unknown state handling - Use set +e in perform_cleanup for graceful degradation - Track removal failures and only count actually deleted items (#180) - Add "Skipped X items (permission denied or in use)" notification - Improve spinner reliability with cooperative stop mechanism (#175)
This commit is contained in:
81
bin/clean.sh
81
bin/clean.sh
@@ -125,16 +125,12 @@ cleanup() {
|
||||
fi
|
||||
CLEANUP_DONE=true
|
||||
|
||||
# Stop all spinners and clear the line
|
||||
if [[ -n "${INLINE_SPINNER_PID:-}" ]] && kill -0 "$INLINE_SPINNER_PID" 2> /dev/null; then
|
||||
kill "$INLINE_SPINNER_PID" 2> /dev/null || true
|
||||
wait "$INLINE_SPINNER_PID" 2> /dev/null || true
|
||||
INLINE_SPINNER_PID=""
|
||||
fi
|
||||
# Stop any inline spinner
|
||||
stop_inline_spinner 2> /dev/null || true
|
||||
|
||||
# Clear any spinner output - spinner outputs to stderr
|
||||
if [[ -t 1 ]]; then
|
||||
printf "\r\033[K" >&2
|
||||
printf "\r\033[K" >&2 || true
|
||||
fi
|
||||
|
||||
# Clean up temporary files
|
||||
@@ -205,6 +201,8 @@ safe_clean() {
|
||||
local total_size_bytes=0
|
||||
local total_count=0
|
||||
local skipped_count=0
|
||||
local removal_failed_count=0
|
||||
local permission_start=${MOLE_PERMISSION_DENIED_COUNT:-0}
|
||||
|
||||
local show_scan_feedback=false
|
||||
if [[ ${#targets[@]} -gt 20 && -t 1 ]]; then
|
||||
@@ -316,17 +314,25 @@ safe_clean() {
|
||||
if [[ -f "$result_file" ]]; then
|
||||
read -r size count < "$result_file" 2> /dev/null || true
|
||||
if [[ "$count" -gt 0 && "$size" -gt 0 ]]; then
|
||||
local removed=1
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
removed=0
|
||||
# Handle symbolic links separately (only remove the link, not the target)
|
||||
if [[ -L "$path" ]]; then
|
||||
rm "$path" 2> /dev/null || true
|
||||
rm "$path" 2> /dev/null && removed=1
|
||||
else
|
||||
safe_remove "$path" true || true
|
||||
if safe_remove "$path" true; then
|
||||
removed=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
((total_size_bytes += size))
|
||||
((total_count += 1))
|
||||
removed_any=1
|
||||
if [[ $removed -eq 1 ]]; then
|
||||
((total_size_bytes += size))
|
||||
((total_count += 1))
|
||||
removed_any=1
|
||||
else
|
||||
((removal_failed_count++))
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
((idx++))
|
||||
@@ -341,17 +347,25 @@ safe_clean() {
|
||||
|
||||
# Optimization: Skip expensive file counting
|
||||
if [[ "$size_bytes" -gt 0 ]]; then
|
||||
local removed=1
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
removed=0
|
||||
# Handle symbolic links separately (only remove the link, not the target)
|
||||
if [[ -L "$path" ]]; then
|
||||
rm "$path" 2> /dev/null || true
|
||||
rm "$path" 2> /dev/null && removed=1
|
||||
else
|
||||
safe_remove "$path" true || true
|
||||
if safe_remove "$path" true; then
|
||||
removed=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
((total_size_bytes += size_bytes))
|
||||
((total_count += 1))
|
||||
removed_any=1
|
||||
if [[ $removed -eq 1 ]]; then
|
||||
((total_size_bytes += size_bytes))
|
||||
((total_count += 1))
|
||||
removed_any=1
|
||||
else
|
||||
((removal_failed_count++))
|
||||
fi
|
||||
fi
|
||||
((idx++))
|
||||
done
|
||||
@@ -361,6 +375,16 @@ safe_clean() {
|
||||
stop_section_spinner
|
||||
fi
|
||||
|
||||
# Track permission failures reported by safe_remove
|
||||
local permission_end=${MOLE_PERMISSION_DENIED_COUNT:-0}
|
||||
if [[ $permission_end -gt $permission_start && $removed_any -eq 0 ]]; then
|
||||
debug_log "Permission denied while cleaning: $description"
|
||||
fi
|
||||
if [[ $removal_failed_count -gt 0 && "$DRY_RUN" != "true" ]]; then
|
||||
echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped $removal_failed_count items (permission denied or in use)"
|
||||
note_activity
|
||||
fi
|
||||
|
||||
if [[ $removed_any -eq 1 ]]; then
|
||||
local size_human=$(bytes_to_human "$((total_size_bytes * 1024))")
|
||||
|
||||
@@ -562,10 +586,28 @@ perform_cleanup() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Hint about Full Disk Access for better results (only if not already granted)
|
||||
if [[ -t 1 && "$DRY_RUN" != "true" ]]; then
|
||||
local fda_status=0
|
||||
has_full_disk_access
|
||||
fda_status=$?
|
||||
if [[ $fda_status -eq 1 ]]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}${ICON_WARNING}${NC} ${GRAY}Tip: Grant Full Disk Access to your terminal in System Settings for best results${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
total_items=0
|
||||
files_cleaned=0
|
||||
total_size_cleaned=0
|
||||
|
||||
local had_errexit=0
|
||||
[[ $- == *e* ]] && had_errexit=1
|
||||
|
||||
# Allow cleanup functions to fail without exiting the script
|
||||
# Individual operations use || true for granular error handling
|
||||
set +e
|
||||
|
||||
# ===== 1. Deep system cleanup (if admin) - Do this first while sudo is fresh =====
|
||||
if [[ "$SYSTEM_CLEAN" == "true" ]]; then
|
||||
start_section "Deep system"
|
||||
@@ -745,6 +787,11 @@ perform_cleanup() {
|
||||
summary_details+=("Free space now: $(get_free_space)")
|
||||
fi
|
||||
|
||||
# Restore strict error handling only if it was enabled
|
||||
if [[ $had_errexit -eq 1 ]]; then
|
||||
set -e
|
||||
fi
|
||||
|
||||
print_summary_block "$summary_heading" "${summary_details[@]}"
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
@@ -340,13 +340,13 @@ main() {
|
||||
fi
|
||||
print_header
|
||||
if ! command -v jq > /dev/null 2>&1; then
|
||||
echo -e "${RED}${ICON_ERROR}${NC} Missing dependency: jq"
|
||||
echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: jq"
|
||||
echo -e "${GRAY}Install with: ${GREEN}brew install jq${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v bc > /dev/null 2>&1; then
|
||||
echo -e "${RED}${ICON_ERROR}${NC} Missing dependency: bc"
|
||||
echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: bc"
|
||||
echo -e "${GRAY}Install with: ${GREEN}brew install bc${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -50,7 +50,7 @@ ICON_ERROR="☻"
|
||||
log_info() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}$1${NC}"; }
|
||||
log_success() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${GREEN}${ICON_SUCCESS}${NC} $1"; }
|
||||
log_warning() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${YELLOW}$1${NC}"; }
|
||||
log_error() { echo -e "${RED}${ICON_ERROR}${NC} $1"; }
|
||||
log_error() { echo -e "${YELLOW}${ICON_ERROR}${NC} $1"; }
|
||||
log_admin() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}${ICON_ADMIN}${NC} $1"; }
|
||||
log_confirm() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}${ICON_CONFIRM}${NC} $1"; }
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/bin/bash
|
||||
# User GUI Applications Cleanup Module
|
||||
# Desktop applications, communication tools, media players, games, utilities
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Clean Xcode and iOS development tools
|
||||
clean_xcode_tools() {
|
||||
# Check if Xcode is running for safer cleanup of critical resources
|
||||
@@ -11,7 +9,6 @@ clean_xcode_tools() {
|
||||
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"
|
||||
@@ -19,7 +16,6 @@ clean_xcode_tools() {
|
||||
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"
|
||||
@@ -28,7 +24,6 @@ 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.)
|
||||
clean_code_editors() {
|
||||
safe_clean ~/Library/Application\ Support/Code/logs/* "VS Code logs"
|
||||
@@ -37,7 +32,6 @@ 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.)
|
||||
clean_communication_apps() {
|
||||
safe_clean ~/Library/Application\ Support/discord/Cache/* "Discord cache"
|
||||
@@ -53,7 +47,6 @@ clean_communication_apps() {
|
||||
safe_clean ~/Library/Caches/com.tencent.WeWorkMac/* "WeCom cache"
|
||||
safe_clean ~/Library/Caches/com.feishu.*/* "Feishu cache"
|
||||
}
|
||||
|
||||
# Clean DingTalk
|
||||
clean_dingtalk() {
|
||||
safe_clean ~/Library/Caches/dd.work.exclusive4aliding/* "DingTalk iDingTalk cache"
|
||||
@@ -61,14 +54,12 @@ clean_dingtalk() {
|
||||
safe_clean ~/Library/Application\ Support/iDingTalk/log/* "DingTalk logs"
|
||||
safe_clean ~/Library/Application\ Support/iDingTalk/holmeslogs/* "DingTalk holmes logs"
|
||||
}
|
||||
|
||||
# Clean 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
|
||||
clean_design_tools() {
|
||||
safe_clean ~/Library/Caches/com.bohemiancoding.sketch3/* "Sketch cache"
|
||||
@@ -78,7 +69,6 @@ clean_design_tools() {
|
||||
safe_clean ~/Library/Caches/com.figma.Desktop/* "Figma cache"
|
||||
# Note: Raycast cache is protected - contains clipboard history (including images)
|
||||
}
|
||||
|
||||
# Clean video editing tools
|
||||
clean_video_tools() {
|
||||
safe_clean ~/Library/Caches/net.telestream.screenflow10/* "ScreenFlow cache"
|
||||
@@ -86,7 +76,6 @@ clean_video_tools() {
|
||||
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
|
||||
clean_3d_tools() {
|
||||
safe_clean ~/Library/Caches/org.blenderfoundation.blender/* "Blender cache"
|
||||
@@ -94,7 +83,6 @@ clean_3d_tools() {
|
||||
safe_clean ~/Library/Caches/com.autodesk.*/* "Autodesk cache"
|
||||
safe_clean ~/Library/Caches/com.sketchup.*/* "SketchUp cache"
|
||||
}
|
||||
|
||||
# Clean productivity apps
|
||||
clean_productivity_apps() {
|
||||
safe_clean ~/Library/Caches/com.tw93.MiaoYan/* "MiaoYan cache"
|
||||
@@ -104,14 +92,12 @@ 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)
|
||||
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)
|
||||
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
|
||||
@@ -124,7 +110,6 @@ clean_media_players() {
|
||||
has_offline_music=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$has_offline_music" == "true" ]]; then
|
||||
echo -e " ${YELLOW}${ICON_WARNING}${NC} Spotify cache protected · offline music detected"
|
||||
note_activity
|
||||
@@ -140,7 +125,6 @@ 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
|
||||
clean_video_players() {
|
||||
safe_clean ~/Library/Caches/com.colliderli.iina "IINA cache"
|
||||
@@ -152,7 +136,6 @@ clean_video_players() {
|
||||
safe_clean ~/Library/Caches/com.douyu.*/* "Douyu cache"
|
||||
safe_clean ~/Library/Caches/com.huya.*/* "Huya cache"
|
||||
}
|
||||
|
||||
# Clean download managers
|
||||
clean_download_managers() {
|
||||
safe_clean ~/Library/Caches/net.xmac.aria2gui "Aria2 cache"
|
||||
@@ -162,7 +145,6 @@ clean_download_managers() {
|
||||
safe_clean ~/Library/Caches/com.folx.*/* "Folx cache"
|
||||
safe_clean ~/Library/Caches/com.charlessoft.pacifist/* "Pacifist cache"
|
||||
}
|
||||
|
||||
# Clean gaming platforms
|
||||
clean_gaming_platforms() {
|
||||
safe_clean ~/Library/Caches/com.valvesoftware.steam/* "Steam cache"
|
||||
@@ -174,33 +156,28 @@ 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
clean_shell_utils() {
|
||||
safe_clean ~/.zcompdump* "Zsh completion cache"
|
||||
@@ -208,13 +185,11 @@ clean_shell_utils() {
|
||||
safe_clean ~/.viminfo.tmp "Vim temporary files"
|
||||
safe_clean ~/.wget-hsts "wget HSTS cache"
|
||||
}
|
||||
|
||||
# Clean input method 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
|
||||
clean_note_apps() {
|
||||
safe_clean ~/Library/Caches/notion.id/* "Notion cache"
|
||||
@@ -224,13 +199,11 @@ 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
|
||||
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
|
||||
clean_remote_desktop() {
|
||||
safe_clean ~/Library/Caches/com.teamviewer.*/* "TeamViewer cache"
|
||||
@@ -238,11 +211,9 @@ clean_remote_desktop() {
|
||||
safe_clean ~/Library/Caches/com.todesk.*/* "ToDesk cache"
|
||||
safe_clean ~/Library/Caches/com.sunlogin.*/* "Sunlogin cache"
|
||||
}
|
||||
|
||||
# Main function to clean all user GUI applications
|
||||
clean_user_gui_applications() {
|
||||
stop_section_spinner
|
||||
|
||||
clean_xcode_tools
|
||||
clean_code_editors
|
||||
clean_communication_apps
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Application Data Cleanup Module
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Clean .DS_Store (Finder metadata), home uses maxdepth 5, excludes slow paths, max 500 files
|
||||
# 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"
|
||||
|
||||
[[ -d "$target" ]] || return 0
|
||||
|
||||
local file_count=0
|
||||
local total_bytes=0
|
||||
local spinner_active="false"
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
MOLE_SPINNER_PREFIX=" "
|
||||
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
|
||||
@@ -30,14 +24,12 @@ 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
|
||||
@@ -47,16 +39,13 @@ clean_ds_store_tree() {
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
rm -f "$ds_file" 2> /dev/null || true
|
||||
fi
|
||||
|
||||
if [[ $file_count -ge $MOLE_MAX_DS_STORE_FILES ]]; then
|
||||
break
|
||||
fi
|
||||
done < <("${find_cmd[@]}" 2> /dev/null || true)
|
||||
|
||||
if [[ "$spinner_active" == "true" ]]; then
|
||||
stop_section_spinner
|
||||
fi
|
||||
|
||||
if [[ $file_count -gt 0 ]]; then
|
||||
local size_human
|
||||
size_human=$(bytes_to_human "$total_bytes")
|
||||
@@ -65,7 +54,6 @@ clean_ds_store_tree() {
|
||||
else
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label ${GREEN}($file_count files, $size_human)${NC}"
|
||||
fi
|
||||
|
||||
local size_kb=$(((total_bytes + 1023) / 1024))
|
||||
((files_cleaned += file_count))
|
||||
((total_size_cleaned += size_kb))
|
||||
@@ -73,24 +61,20 @@ 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
|
||||
# Scan system for installed application bundle IDs
|
||||
# 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
|
||||
local cache_file="$HOME/.cache/mole/installed_apps_cache"
|
||||
local cache_age_seconds=300 # 5 minutes
|
||||
|
||||
if [[ -f "$cache_file" ]]; then
|
||||
local cache_mtime=$(get_file_mtime "$cache_file")
|
||||
local current_time=$(date +%s)
|
||||
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
|
||||
@@ -105,19 +89,15 @@ scan_installed_apps() {
|
||||
fi
|
||||
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
|
||||
local scan_tmp_dir=$(create_temp_dir)
|
||||
|
||||
# Parallel scan for applications
|
||||
local pids=()
|
||||
local dir_idx=0
|
||||
@@ -129,109 +109,86 @@ scan_installed_apps() {
|
||||
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"
|
||||
[[ ! -f "$plist_path" ]] && continue
|
||||
|
||||
local bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$plist_path" 2> /dev/null || echo "")
|
||||
|
||||
if [[ -n "$bundle_id" ]]; then
|
||||
echo "$bundle_id"
|
||||
((count++))
|
||||
|
||||
fi
|
||||
done
|
||||
|
||||
) > "$scan_tmp_dir/apps_${dir_idx}.txt" &
|
||||
pids+=($!)
|
||||
((dir_idx++))
|
||||
done
|
||||
|
||||
# Get running applications and LaunchAgents in parallel
|
||||
(
|
||||
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"
|
||||
) &
|
||||
pids+=($!)
|
||||
|
||||
(
|
||||
run_with_timeout 5 find ~/Library/LaunchAgents /Library/LaunchAgents \
|
||||
-name "*.plist" -type f 2> /dev/null |
|
||||
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
|
||||
done
|
||||
|
||||
debug_log "All background processes completed"
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
# Check if bundle is orphaned
|
||||
# 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)
|
||||
local days_since_modified=$(((current_epoch - last_modified_epoch) / 86400))
|
||||
|
||||
if [[ $days_since_modified -lt ${ORPHAN_AGE_THRESHOLD:-60} ]]; then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# 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
|
||||
# Protects system apps, major vendors, scans /Applications+running processes
|
||||
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
|
||||
@@ -239,24 +196,19 @@ clean_orphaned_app_data() {
|
||||
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)
|
||||
local -a resource_types=(
|
||||
@@ -267,49 +219,39 @@ clean_orphaned_app_data() {
|
||||
"$HOME/Library/HTTPStorages|HTTP|com.*:org.*:net.*:io.*"
|
||||
"$HOME/Library/Cookies|Cookies|*.binarycookies"
|
||||
)
|
||||
|
||||
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
|
||||
@@ -324,14 +266,11 @@ clean_orphaned_app_data() {
|
||||
done
|
||||
done
|
||||
done
|
||||
|
||||
stop_section_spinner
|
||||
|
||||
if [[ $orphaned_count -gt 0 ]]; then
|
||||
local orphaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}')
|
||||
echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count items (~${orphaned_mb}MB)"
|
||||
note_activity
|
||||
fi
|
||||
|
||||
rm -f "$installed_bundles"
|
||||
}
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Clean Homebrew caches and remove orphaned dependencies
|
||||
# Skips if run within 7 days, runs cleanup/autoremove in parallel with 120s timeout
|
||||
# Env: MO_BREW_TIMEOUT, DRY_RUN
|
||||
# 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
|
||||
local brew_cache_file="${HOME}/.cache/mole/brew_last_cleanup"
|
||||
local cache_valid_days=7
|
||||
local should_skip=false
|
||||
|
||||
if [[ -f "$brew_cache_file" ]]; then
|
||||
local last_cleanup
|
||||
last_cleanup=$(cat "$brew_cache_file" 2> /dev/null || echo "0")
|
||||
@@ -25,15 +21,12 @@ clean_homebrew() {
|
||||
current_time=$(date +%s)
|
||||
local time_diff=$((current_time - last_cleanup))
|
||||
local days_diff=$((time_diff / 86400))
|
||||
|
||||
if [[ $days_diff -lt $cache_valid_days ]]; then
|
||||
should_skip=true
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew · cleaned ${days_diff}d ago, skipped"
|
||||
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
|
||||
@@ -42,13 +35,11 @@ clean_homebrew() {
|
||||
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
|
||||
if [[ -t 1 ]]; then
|
||||
if [[ "$skip_cleanup" == "true" ]]; then
|
||||
@@ -57,30 +48,23 @@ clean_homebrew() {
|
||||
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Homebrew cleanup and autoremove..."
|
||||
fi
|
||||
fi
|
||||
|
||||
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
|
||||
brew_tmp_file=$(create_temp_file)
|
||||
(brew cleanup > "$brew_tmp_file" 2>&1) &
|
||||
brew_pid=$!
|
||||
fi
|
||||
|
||||
autoremove_tmp_file=$(create_temp_file)
|
||||
(brew autoremove > "$autoremove_tmp_file" 2>&1) &
|
||||
autoremove_pid=$!
|
||||
|
||||
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
|
||||
@@ -88,14 +72,11 @@ clean_homebrew() {
|
||||
kill -TERM $autoremove_pid 2> /dev/null || true
|
||||
break
|
||||
fi
|
||||
|
||||
[[ -n "$brew_pid" ]] && { kill -0 $brew_pid 2> /dev/null || brew_done=true; }
|
||||
kill -0 $autoremove_pid 2> /dev/null || autoremove_done=true
|
||||
|
||||
sleep 1
|
||||
((elapsed++))
|
||||
done
|
||||
|
||||
# Wait for processes to finish
|
||||
local brew_success=false
|
||||
if [[ "$skip_cleanup" == "false" && -n "$brew_pid" ]]; then
|
||||
@@ -103,14 +84,11 @@ clean_homebrew() {
|
||||
brew_success=true
|
||||
fi
|
||||
fi
|
||||
|
||||
local autoremove_success=false
|
||||
if wait $autoremove_pid 2> /dev/null; then
|
||||
autoremove_success=true
|
||||
fi
|
||||
|
||||
if [[ -t 1 ]]; then stop_inline_spinner; fi
|
||||
|
||||
# Process cleanup output and extract metrics
|
||||
if [[ "$skip_cleanup" == "true" ]]; then
|
||||
# Cleanup was skipped due to small cache size
|
||||
@@ -122,7 +100,6 @@ clean_homebrew() {
|
||||
local removed_count freed_space
|
||||
removed_count=$(printf '%s\n' "$brew_output" | grep -c "Removing:" 2> /dev/null || true)
|
||||
freed_space=$(printf '%s\n' "$brew_output" | grep -o "[0-9.]*[KMGT]B freed" 2> /dev/null | tail -1 || true)
|
||||
|
||||
if [[ $removed_count -gt 0 ]] || [[ -n "$freed_space" ]]; then
|
||||
if [[ -n "$freed_space" ]]; then
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup ${GREEN}($freed_space)${NC}"
|
||||
@@ -133,21 +110,18 @@ clean_homebrew() {
|
||||
elif [[ $elapsed -ge $timeout_seconds ]]; then
|
||||
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
|
||||
if [[ "$autoremove_success" == "true" && -f "$autoremove_tmp_file" ]]; then
|
||||
local autoremove_output
|
||||
autoremove_output=$(cat "$autoremove_tmp_file" 2> /dev/null || echo "")
|
||||
local removed_packages
|
||||
removed_packages=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" 2> /dev/null || true)
|
||||
|
||||
if [[ $removed_packages -gt 0 ]]; then
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed orphaned dependencies (${removed_packages} packages)"
|
||||
fi
|
||||
elif [[ $elapsed -ge $timeout_seconds ]]; then
|
||||
echo -e " ${YELLOW}${ICON_WARNING}${NC} Autoremove timed out · run ${GRAY}brew autoremove${NC} manually"
|
||||
fi
|
||||
|
||||
# Update cache timestamp on successful completion or when cleanup was intelligently skipped
|
||||
# This prevents repeated cache size checks within the 7-day window
|
||||
if [[ "$skip_cleanup" == "true" ]] || [[ "$brew_success" == "true" ]] || [[ "$autoremove_success" == "true" ]]; then
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Cache Cleanup Module
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Trigger all TCC permission dialogs upfront to avoid random interruptions
|
||||
# Only runs once (uses ~/.cache/mole/permissions_granted flag)
|
||||
# Trigger all TCC permission dialogs upfront to avoid random 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"
|
||||
@@ -22,14 +17,12 @@ 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
|
||||
local needs_permission_check=false
|
||||
if ! ls "$HOME/Library/Caches" > /dev/null 2>&1; then
|
||||
needs_permission_check=true
|
||||
fi
|
||||
|
||||
if [[ "$needs_permission_check" == "true" ]]; then
|
||||
echo ""
|
||||
echo -e "${BLUE}First-time setup${NC}"
|
||||
@@ -38,44 +31,35 @@ check_tcc_permissions() {
|
||||
echo ""
|
||||
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
|
||||
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)
|
||||
ensure_user_file "$permission_flag"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Clean browser Service Worker cache, protecting web editing tools (capcut, photopea, pixlr)
|
||||
# Args: $1=browser_name, $2=cache_path
|
||||
# Clean browser Service Worker cache, protecting web editing tools (capcut, photopea, pixlr)
|
||||
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"
|
||||
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
|
||||
@@ -85,7 +69,6 @@ clean_service_worker_cache() {
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Clean if not protected
|
||||
if [[ "$is_protected" == "false" ]]; then
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
@@ -94,7 +77,6 @@ clean_service_worker_cache() {
|
||||
cleaned_size=$((cleaned_size + size))
|
||||
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
|
||||
@@ -102,7 +84,6 @@ clean_service_worker_cache() {
|
||||
stop_inline_spinner
|
||||
spinner_was_running=true
|
||||
fi
|
||||
|
||||
local cleaned_mb=$((cleaned_size / 1024))
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
if [[ $protected_count -gt 0 ]]; then
|
||||
@@ -114,19 +95,16 @@ 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
|
||||
}
|
||||
|
||||
# Clean Next.js (.next/cache) and Python (__pycache__) build caches
|
||||
# Uses maxdepth 3, excludes Library/.Trash/node_modules, 10s timeout per scan
|
||||
# Clean Next.js (.next/cache) and Python (__pycache__) build caches
|
||||
clean_project_caches() {
|
||||
stop_inline_spinner 2> /dev/null || true
|
||||
|
||||
# Quick check: skip if user likely doesn't have development projects
|
||||
local has_dev_projects=false
|
||||
local -a common_dev_dirs=(
|
||||
@@ -149,14 +127,12 @@ clean_project_caches() {
|
||||
"$HOME/dotnet"
|
||||
"$HOME/node"
|
||||
)
|
||||
|
||||
for dir in "${common_dev_dirs[@]}"; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
has_dev_projects=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# If no common dev directories found, perform feature-based detection
|
||||
# Check for project markers in $HOME (node_modules, .git, target, etc.)
|
||||
if [[ "$has_dev_projects" == "false" ]]; then
|
||||
@@ -170,14 +146,12 @@ clean_project_caches() {
|
||||
"pom.xml"
|
||||
"build.gradle"
|
||||
)
|
||||
|
||||
local spinner_active=false
|
||||
if [[ -t 1 ]]; then
|
||||
MOLE_SPINNER_PREFIX=" "
|
||||
start_inline_spinner "Detecting dev projects..."
|
||||
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
|
||||
@@ -185,26 +159,21 @@ clean_project_caches() {
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
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
|
||||
MOLE_SPINNER_PREFIX=" "
|
||||
start_inline_spinner "Searching project caches..."
|
||||
fi
|
||||
|
||||
local nextjs_tmp_file
|
||||
nextjs_tmp_file=$(create_temp_file)
|
||||
local pycache_tmp_file
|
||||
pycache_tmp_file=$(create_temp_file)
|
||||
local find_timeout=10
|
||||
|
||||
# 1. Start Next.js search
|
||||
(
|
||||
command find "$HOME" -P -mount -type d -name ".next" -maxdepth 3 \
|
||||
@@ -215,7 +184,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 \
|
||||
@@ -226,7 +194,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
|
||||
@@ -237,13 +204,11 @@ clean_project_caches() {
|
||||
sleep $check_interval
|
||||
elapsed=$(echo "$elapsed + $check_interval" | awk '{print $1 + $2}')
|
||||
done
|
||||
|
||||
# 4. Clean up any stuck processes
|
||||
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
|
||||
@@ -253,28 +218,23 @@ 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
|
||||
fi
|
||||
done
|
||||
|
||||
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
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
#!/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
|
||||
# Note: Try to estimate potential savings (many tool caches don't have a direct path,
|
||||
# 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,
|
||||
clean_tool_cache() {
|
||||
local description="$1"
|
||||
shift
|
||||
@@ -18,30 +16,27 @@ clean_tool_cache() {
|
||||
else
|
||||
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $description · would clean"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Clean npm cache (command + directories)
|
||||
# npm cache clean clears official npm cache, safe_clean handles alternative package managers
|
||||
# Env: DRY_RUN
|
||||
# npm cache clean clears official npm cache, safe_clean handles alternative package managers
|
||||
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"
|
||||
@@ -51,24 +46,21 @@ clean_dev_npm() {
|
||||
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)
|
||||
# pip cache purge clears official pip cache, safe_clean handles other Python tools
|
||||
# Env: DRY_RUN
|
||||
# pip cache purge clears official pip cache, safe_clean handles other Python tools
|
||||
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"
|
||||
@@ -84,10 +76,9 @@ clean_dev_python() {
|
||||
safe_clean ~/anaconda3/pkgs/* "Anaconda packages cache"
|
||||
safe_clean ~/.cache/wandb/* "Weights & Biases cache"
|
||||
}
|
||||
|
||||
# Clean Go cache (command + directories)
|
||||
# go clean handles build and module caches comprehensively
|
||||
# Env: DRY_RUN
|
||||
# go clean handles build and module caches comprehensively
|
||||
clean_dev_go() {
|
||||
if command -v go > /dev/null 2>&1; then
|
||||
# clean_tool_cache now calculates size before cleanup for better statistics
|
||||
@@ -95,16 +86,14 @@ clean_dev_go() {
|
||||
note_activity
|
||||
fi
|
||||
}
|
||||
|
||||
# Clean Rust/cargo cache directories
|
||||
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"
|
||||
}
|
||||
|
||||
# Clean Docker cache (command + directories)
|
||||
# Env: DRY_RUN
|
||||
# Clean Docker cache (command + directories)
|
||||
clean_dev_docker() {
|
||||
if command -v docker > /dev/null 2>&1; then
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
@@ -115,7 +104,6 @@ clean_dev_docker() {
|
||||
docker_running=true
|
||||
fi
|
||||
stop_section_spinner
|
||||
|
||||
if [[ "$docker_running" == "true" ]]; then
|
||||
clean_tool_cache "Docker build cache" docker builder prune -af
|
||||
else
|
||||
@@ -131,7 +119,6 @@ clean_dev_docker() {
|
||||
retry_success=true
|
||||
fi
|
||||
stop_section_spinner
|
||||
|
||||
if [[ "$retry_success" == "true" ]]; then
|
||||
clean_tool_cache "Docker build cache" docker builder prune -af
|
||||
else
|
||||
@@ -150,12 +137,10 @@ clean_dev_docker() {
|
||||
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Docker build cache · would clean"
|
||||
fi
|
||||
fi
|
||||
|
||||
safe_clean ~/.docker/buildx/cache/* "Docker BuildX cache"
|
||||
}
|
||||
|
||||
# Clean Nix package manager
|
||||
# Env: DRY_RUN
|
||||
# Clean Nix package manager
|
||||
clean_dev_nix() {
|
||||
if command -v nix-collect-garbage > /dev/null 2>&1; then
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
@@ -166,7 +151,6 @@ clean_dev_nix() {
|
||||
note_activity
|
||||
fi
|
||||
}
|
||||
|
||||
# Clean cloud CLI tools cache
|
||||
clean_dev_cloud() {
|
||||
safe_clean ~/.kube/cache/* "Kubernetes cache"
|
||||
@@ -175,7 +159,6 @@ clean_dev_cloud() {
|
||||
safe_clean ~/.config/gcloud/logs/* "Google Cloud logs"
|
||||
safe_clean ~/.azure/logs/* "Azure CLI logs"
|
||||
}
|
||||
|
||||
# Clean frontend build tool caches
|
||||
clean_dev_frontend() {
|
||||
safe_clean ~/.cache/typescript/* "TypeScript cache"
|
||||
@@ -190,23 +173,20 @@ 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)
|
||||
# DeviceSupport files accumulate for each iOS version connected
|
||||
# Simulator runtime caches can grow large over time
|
||||
# DeviceSupport files accumulate for each iOS version connected
|
||||
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
|
||||
@@ -218,7 +198,6 @@ clean_dev_mobile() {
|
||||
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
|
||||
@@ -226,11 +205,9 @@ clean_dev_mobile() {
|
||||
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
|
||||
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"
|
||||
safe_clean ~/.cache/flutter/* "Flutter cache"
|
||||
@@ -239,7 +216,6 @@ 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
|
||||
clean_dev_jvm() {
|
||||
safe_clean ~/.gradle/caches/* "Gradle caches"
|
||||
@@ -247,7 +223,6 @@ clean_dev_jvm() {
|
||||
safe_clean ~/.sbt/* "SBT cache"
|
||||
safe_clean ~/.ivy2/cache/* "Ivy cache"
|
||||
}
|
||||
|
||||
# Clean other language tools
|
||||
clean_dev_other_langs() {
|
||||
safe_clean ~/.bundle/cache/* "Ruby Bundler cache"
|
||||
@@ -258,7 +233,6 @@ clean_dev_other_langs() {
|
||||
safe_clean ~/.cache/zig/* "Zig cache"
|
||||
safe_clean ~/Library/Caches/deno/* "Deno cache"
|
||||
}
|
||||
|
||||
# Clean CI/CD and DevOps tools
|
||||
clean_dev_cicd() {
|
||||
safe_clean ~/.cache/terraform/* "Terraform cache"
|
||||
@@ -270,7 +244,6 @@ clean_dev_cicd() {
|
||||
safe_clean ~/.circleci/cache/* "CircleCI cache"
|
||||
safe_clean ~/.sonar/* "SonarQube cache"
|
||||
}
|
||||
|
||||
# Clean database tools
|
||||
clean_dev_database() {
|
||||
safe_clean ~/Library/Caches/com.sequel-ace.sequel-ace/* "Sequel Ace cache"
|
||||
@@ -280,7 +253,6 @@ 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
|
||||
clean_dev_api_tools() {
|
||||
safe_clean ~/Library/Caches/com.postmanlabs.mac/* "Postman cache"
|
||||
@@ -290,7 +262,6 @@ 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
|
||||
clean_dev_misc() {
|
||||
safe_clean ~/Library/Caches/com.unity3d.*/* "Unity cache"
|
||||
@@ -301,7 +272,6 @@ clean_dev_misc() {
|
||||
safe_clean ~/Library/Caches/KSCrash/* "KSCrash reports"
|
||||
safe_clean ~/Library/Caches/com.crashlytics.data/* "Crashlytics data"
|
||||
}
|
||||
|
||||
# Clean shell and version control
|
||||
clean_dev_shell() {
|
||||
safe_clean ~/.gitconfig.lock "Git config lock"
|
||||
@@ -312,7 +282,6 @@ clean_dev_shell() {
|
||||
safe_clean ~/.zsh_history.bak* "Zsh history backup"
|
||||
safe_clean ~/.cache/pre-commit/* "pre-commit cache"
|
||||
}
|
||||
|
||||
# Clean network utilities
|
||||
clean_dev_network() {
|
||||
safe_clean ~/.cache/curl/* "curl cache"
|
||||
@@ -320,26 +289,22 @@ clean_dev_network() {
|
||||
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
|
||||
# This is fast and safe - skip complex checks for files with existing base DB
|
||||
# Env: DRY_RUN
|
||||
# This is fast and safe - skip complex checks for files with existing base DB
|
||||
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
|
||||
# Calls all specialized cleanup functions
|
||||
# Env: DRY_RUN
|
||||
# Calls all specialized cleanup functions
|
||||
clean_developer_tools() {
|
||||
stop_section_spinner
|
||||
|
||||
# Clean SQLite temporary files first
|
||||
clean_sqlite_temp_files
|
||||
|
||||
clean_dev_npm
|
||||
clean_dev_python
|
||||
clean_dev_go
|
||||
@@ -349,10 +314,8 @@ 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
|
||||
clean_dev_other_langs
|
||||
@@ -361,16 +324,13 @@ 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)
|
||||
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
|
||||
@@ -384,6 +344,5 @@ clean_developer_tools() {
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
clean_homebrew
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/bin/bash
|
||||
# Project Purge Module (mo purge)
|
||||
# Removes heavy project build artifacts and dependencies
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Targets to look for (heavy build artifacts)
|
||||
readonly PURGE_TARGETS=(
|
||||
"node_modules"
|
||||
@@ -23,14 +21,11 @@ readonly PURGE_TARGETS=(
|
||||
".parcel-cache" # Parcel bundler
|
||||
".dart_tool" # Flutter/Dart build cache
|
||||
)
|
||||
|
||||
# Minimum age in days before considering for cleanup
|
||||
readonly MIN_AGE_DAYS=7
|
||||
|
||||
# Scan depth defaults (relative to search root)
|
||||
readonly PURGE_MIN_DEPTH_DEFAULT=2
|
||||
readonly PURGE_MAX_DEPTH_DEFAULT=8
|
||||
|
||||
# Search paths (only project directories)
|
||||
readonly PURGE_SEARCH_PATHS=(
|
||||
"$HOME/www"
|
||||
@@ -42,43 +37,36 @@ readonly PURGE_SEARCH_PATHS=(
|
||||
"$HOME/Repos"
|
||||
"$HOME/Development"
|
||||
)
|
||||
|
||||
# Check if path is safe to clean (must be inside a project directory)
|
||||
# Args: $1 - path to check
|
||||
# Check if path is safe to clean (must 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
|
||||
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
|
||||
# Scan for purge targets using strict project boundary checks
|
||||
# Args: $1 - search path, $2 - output file
|
||||
# Scan for purge targets using strict project boundary checks
|
||||
scan_purge_targets() {
|
||||
local search_path="$1"
|
||||
local output_file="$2"
|
||||
local min_depth="${MOLE_PURGE_MIN_DEPTH:-$PURGE_MIN_DEPTH_DEFAULT}"
|
||||
local max_depth="${MOLE_PURGE_MAX_DEPTH:-$PURGE_MAX_DEPTH_DEFAULT}"
|
||||
|
||||
if [[ ! "$min_depth" =~ ^[0-9]+$ ]]; then
|
||||
min_depth="$PURGE_MIN_DEPTH_DEFAULT"
|
||||
fi
|
||||
@@ -88,11 +76,9 @@ scan_purge_targets() {
|
||||
if [[ "$max_depth" -lt "$min_depth" ]]; then
|
||||
max_depth="$min_depth"
|
||||
fi
|
||||
|
||||
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=(
|
||||
@@ -108,11 +94,9 @@ scan_purge_targets() {
|
||||
"--exclude" ".Trash"
|
||||
"--exclude" "Applications"
|
||||
)
|
||||
|
||||
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
|
||||
@@ -123,68 +107,55 @@ scan_purge_targets() {
|
||||
# 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)).
|
||||
|
||||
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
|
||||
((i++))
|
||||
done
|
||||
|
||||
command find "$search_path" -mindepth "$min_depth" -maxdepth "$max_depth" -type d \
|
||||
\( "${find_expr[@]}" \) 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"
|
||||
fi
|
||||
}
|
||||
|
||||
# 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
|
||||
@@ -194,39 +165,33 @@ filter_nested_artifacts() {
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$is_nested" == "false" ]]; then
|
||||
echo "$item"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Check if a path was modified recently (safety check)
|
||||
# Args: $1 - path
|
||||
# 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)
|
||||
local age_seconds=$((current_time - mod_time))
|
||||
local age_in_days=$((age_seconds / 86400))
|
||||
|
||||
if [[ $age_in_days -lt $age_days ]]; then
|
||||
return 0 # Recently modified
|
||||
else
|
||||
return 1 # Old enough to clean
|
||||
fi
|
||||
}
|
||||
|
||||
# Get human-readable size of directory
|
||||
# Args: $1 - path
|
||||
# Get human-readable size of directory
|
||||
get_dir_size_kb() {
|
||||
local path="$1"
|
||||
if [[ -d "$path" ]]; then
|
||||
@@ -235,20 +200,17 @@ get_dir_size_kb() {
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Simple category selector (for purge only)
|
||||
# Args: category names and metadata as arrays (passed via global vars)
|
||||
# Returns: selected indices in PURGE_SELECTION_RESULT (comma-separated)
|
||||
# Uses PURGE_RECENT_CATEGORIES to mark categories with recent items (default unselected)
|
||||
# Returns: selected indices in PURGE_SELECTION_RESULT (comma-separated)
|
||||
select_purge_categories() {
|
||||
local -a categories=("$@")
|
||||
local total_items=${#categories[@]}
|
||||
local clear_line=$'\r\033[2K'
|
||||
|
||||
if [[ $total_items -eq 0 ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Initialize selection (all selected by default, except recent ones)
|
||||
local -a selected=()
|
||||
IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}"
|
||||
@@ -260,13 +222,11 @@ select_purge_categories() {
|
||||
selected[i]=true
|
||||
fi
|
||||
done
|
||||
|
||||
local cursor_pos=0
|
||||
local original_stty=""
|
||||
if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then
|
||||
original_stty=$(stty -g 2> /dev/null || echo "")
|
||||
fi
|
||||
|
||||
# Terminal control functions
|
||||
restore_terminal() {
|
||||
trap - EXIT INT TERM
|
||||
@@ -275,13 +235,11 @@ select_purge_categories() {
|
||||
stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# shellcheck disable=SC2329
|
||||
handle_interrupt() {
|
||||
restore_terminal
|
||||
exit 130
|
||||
}
|
||||
|
||||
draw_menu() {
|
||||
printf "\033[H"
|
||||
# Calculate total size of selected items for header
|
||||
@@ -296,48 +254,37 @@ select_purge_categories() {
|
||||
done
|
||||
local selected_gb
|
||||
selected_gb=$(echo "scale=1; $selected_size/1024/1024" | bc)
|
||||
|
||||
printf "%s\n" "$clear_line"
|
||||
printf "%s${PURPLE_BOLD}Select Categories to Clean${NC} ${GRAY}- ${selected_gb}GB ($selected_count selected)${NC}\n" "$clear_line"
|
||||
printf "%s\n" "$clear_line"
|
||||
|
||||
IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}"
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
local checkbox="$ICON_EMPTY"
|
||||
[[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID"
|
||||
|
||||
local recent_marker=""
|
||||
[[ ${recent_flags[i]:-false} == "true" ]] && recent_marker=" ${GRAY}| Recent${NC}"
|
||||
|
||||
if [[ $i -eq $cursor_pos ]]; then
|
||||
printf "%s${CYAN}${ICON_ARROW} %s %s%s${NC}\n" "$clear_line" "$checkbox" "${categories[i]}" "$recent_marker"
|
||||
else
|
||||
printf "%s %s %s%s\n" "$clear_line" "$checkbox" "${categories[i]}" "$recent_marker"
|
||||
fi
|
||||
done
|
||||
|
||||
printf "%s\n" "$clear_line"
|
||||
printf "%s${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space Select | Enter Confirm | A All | I Invert | Q Quit${NC}\n" "$clear_line"
|
||||
}
|
||||
|
||||
trap restore_terminal EXIT
|
||||
trap handle_interrupt INT TERM
|
||||
|
||||
# Preserve interrupt character for Ctrl-C
|
||||
stty -echo -icanon intr ^C 2> /dev/null || true
|
||||
hide_cursor
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
clear_screen
|
||||
fi
|
||||
|
||||
# Main loop
|
||||
while true; do
|
||||
draw_menu
|
||||
|
||||
# Read key
|
||||
IFS= read -r -s -n1 key || key=""
|
||||
|
||||
case "$key" in
|
||||
$'\x1b')
|
||||
# Arrow keys or ESC
|
||||
@@ -393,20 +340,17 @@ select_purge_categories() {
|
||||
PURGE_SELECTION_RESULT+="$i"
|
||||
fi
|
||||
done
|
||||
|
||||
restore_terminal
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Main cleanup function - scans and prompts user to select artifacts to clean
|
||||
clean_project_artifacts() {
|
||||
local -a all_found_items=()
|
||||
local -a safe_to_clean=()
|
||||
local -a recently_modified=()
|
||||
|
||||
# Set up cleanup on interrupt
|
||||
# Note: Declared without 'local' so cleanup_scan trap can access them
|
||||
scan_pids=()
|
||||
@@ -428,35 +372,29 @@ clean_project_artifacts() {
|
||||
exit 130
|
||||
}
|
||||
trap cleanup_scan INT TERM
|
||||
|
||||
# Start parallel scanning of all paths at once
|
||||
if [[ -t 1 ]]; then
|
||||
start_inline_spinner "Scanning projects..."
|
||||
fi
|
||||
|
||||
# Launch all scans in parallel
|
||||
for path in "${PURGE_SEARCH_PATHS[@]}"; do
|
||||
if [[ -d "$path" ]]; then
|
||||
local scan_output
|
||||
scan_output=$(mktemp)
|
||||
scan_temps+=("$scan_output")
|
||||
|
||||
# Launch scan in background for true parallelism
|
||||
scan_purge_targets "$path" "$scan_output" &
|
||||
local scan_pid=$!
|
||||
scan_pids+=("$scan_pid")
|
||||
fi
|
||||
done
|
||||
|
||||
# Wait for all scans to complete
|
||||
for pid in "${scan_pids[@]+"${scan_pids[@]}"}"; do
|
||||
wait "$pid" 2> /dev/null || true
|
||||
done
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
stop_inline_spinner
|
||||
fi
|
||||
|
||||
# Collect all results
|
||||
for scan_output in "${scan_temps[@]+"${scan_temps[@]}"}"; do
|
||||
if [[ -f "$scan_output" ]]; then
|
||||
@@ -468,17 +406,14 @@ clean_project_artifacts() {
|
||||
rm -f "$scan_output"
|
||||
fi
|
||||
done
|
||||
|
||||
# Clean up trap
|
||||
trap - INT TERM
|
||||
|
||||
if [[ ${#all_found_items[@]} -eq 0 ]]; then
|
||||
echo ""
|
||||
echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No old project artifacts to clean"
|
||||
printf '\n'
|
||||
return 2 # Special code: nothing to clean
|
||||
fi
|
||||
|
||||
# Mark recently modified items (for default selection state)
|
||||
for item in "${all_found_items[@]}"; do
|
||||
if is_recently_modified "$item"; then
|
||||
@@ -487,23 +422,19 @@ clean_project_artifacts() {
|
||||
# Add all items to safe_to_clean, let user choose
|
||||
safe_to_clean+=("$item")
|
||||
done
|
||||
|
||||
# Build menu options - one per artifact
|
||||
if [[ -t 1 ]]; then
|
||||
start_inline_spinner "Calculating sizes..."
|
||||
fi
|
||||
|
||||
local -a menu_options=()
|
||||
local -a item_paths=()
|
||||
local -a item_sizes=()
|
||||
local -a item_recent_flags=()
|
||||
|
||||
# Helper to get project name from path
|
||||
# For ~/www/pake/src-tauri/target -> returns "pake"
|
||||
# For ~/www/project/node_modules/xxx/node_modules -> returns "project"
|
||||
get_project_name() {
|
||||
local path="$1"
|
||||
|
||||
# Find the project root by looking for direct child of search paths
|
||||
local search_roots=()
|
||||
if [[ ${#PURGE_SEARCH_PATHS[@]} -gt 0 ]]; then
|
||||
@@ -511,7 +442,6 @@ clean_project_artifacts() {
|
||||
else
|
||||
search_roots=("$HOME/www" "$HOME/dev" "$HOME/Projects")
|
||||
fi
|
||||
|
||||
for root in "${search_roots[@]}"; do
|
||||
# Normalize trailing slash for consistent matching
|
||||
root="${root%/}"
|
||||
@@ -523,44 +453,36 @@ clean_project_artifacts() {
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# Fallback: use grandparent directory
|
||||
dirname "$(dirname "$path")" | xargs basename
|
||||
}
|
||||
|
||||
# Format display with alignment (like app_selector)
|
||||
format_purge_display() {
|
||||
local project_name="$1"
|
||||
local artifact_type="$2"
|
||||
local size_str="$3"
|
||||
|
||||
# Terminal width for alignment
|
||||
local terminal_width=$(tput cols 2> /dev/null || echo 80)
|
||||
local fixed_width=28 # Reserve for type and size
|
||||
local available_width=$((terminal_width - fixed_width))
|
||||
|
||||
# Bounds: 24-35 chars for project name
|
||||
[[ $available_width -lt 24 ]] && available_width=24
|
||||
[[ $available_width -gt 35 ]] && available_width=35
|
||||
|
||||
# Truncate project name if needed
|
||||
local truncated_name=$(truncate_by_display_width "$project_name" "$available_width")
|
||||
local current_width=$(get_display_width "$truncated_name")
|
||||
local char_count=${#truncated_name}
|
||||
local padding=$((available_width - current_width))
|
||||
local printf_width=$((char_count + padding))
|
||||
|
||||
# Format: "project_name size | artifact_type"
|
||||
printf "%-*s %9s | %-13s" "$printf_width" "$truncated_name" "$size_str" "$artifact_type"
|
||||
}
|
||||
|
||||
# Build menu options - one line per artifact
|
||||
for item in "${safe_to_clean[@]}"; do
|
||||
local project_name=$(get_project_name "$item")
|
||||
local artifact_type=$(basename "$item")
|
||||
local size_kb=$(get_dir_size_kb "$item")
|
||||
local size_human=$(bytes_to_human "$((size_kb * 1024))")
|
||||
|
||||
# Check if recent
|
||||
local is_recent=false
|
||||
for recent_item in "${recently_modified[@]+"${recently_modified[@]}"}"; do
|
||||
@@ -569,17 +491,14 @@ clean_project_artifacts() {
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
menu_options+=("$(format_purge_display "$project_name" "$artifact_type" "$size_human")")
|
||||
item_paths+=("$item")
|
||||
item_sizes+=("$size_kb")
|
||||
item_recent_flags+=("$is_recent")
|
||||
done
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
stop_inline_spinner
|
||||
fi
|
||||
|
||||
# Set global vars for selector
|
||||
export PURGE_CATEGORY_SIZES=$(
|
||||
IFS=,
|
||||
@@ -589,7 +508,6 @@ clean_project_artifacts() {
|
||||
IFS=,
|
||||
echo "${item_recent_flags[*]}"
|
||||
)
|
||||
|
||||
# Interactive selection (only if terminal is available)
|
||||
PURGE_SELECTION_RESULT=""
|
||||
if [[ -t 0 ]]; then
|
||||
@@ -606,7 +524,6 @@ clean_project_artifacts() {
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "$PURGE_SELECTION_RESULT" ]]; then
|
||||
echo ""
|
||||
echo -e "${GRAY}No items selected${NC}"
|
||||
@@ -614,48 +531,38 @@ clean_project_artifacts() {
|
||||
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Clean selected items
|
||||
echo ""
|
||||
IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT"
|
||||
|
||||
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
|
||||
local cleaned_count=0
|
||||
|
||||
for idx in "${selected_indices[@]}"; do
|
||||
local item_path="${item_paths[idx]}"
|
||||
local artifact_type=$(basename "$item_path")
|
||||
local project_name=$(get_project_name "$item_path")
|
||||
local size_kb="${item_sizes[idx]}"
|
||||
local size_human=$(bytes_to_human "$((size_kb * 1024))")
|
||||
|
||||
# Safety checks
|
||||
if [[ -z "$item_path" || "$item_path" == "/" || "$item_path" == "$HOME" || "$item_path" != "$HOME/"* ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
start_inline_spinner "Cleaning $project_name/$artifact_type..."
|
||||
fi
|
||||
|
||||
if [[ -e "$item_path" ]]; then
|
||||
safe_remove "$item_path" true
|
||||
|
||||
if [[ ! -e "$item_path" ]]; then
|
||||
local current_total=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0")
|
||||
echo "$((current_total + size_kb))" > "$stats_dir/purge_stats"
|
||||
((cleaned_count++))
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
stop_inline_spinner
|
||||
echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_name - $artifact_type ${GREEN}($size_human)${NC}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Update count
|
||||
echo "$cleaned_count" > "$stats_dir/purge_count"
|
||||
|
||||
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
|
||||
}
|
||||
|
||||
@@ -1,35 +1,28 @@
|
||||
#!/bin/bash
|
||||
# System-Level Cleanup Module
|
||||
# Deep system cleanup (requires sudo) and Time Machine failed backups
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Deep system cleanup (requires sudo)
|
||||
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
|
||||
safe_sudo_find_delete "/Library/Caches" "*.log" "$MOLE_LOG_AGE_DAYS" "f" && cache_cleaned=1 || true
|
||||
[[ $cache_cleaned -eq 1 ]] && log_success "System caches"
|
||||
|
||||
# Clean temporary files (macOS /tmp is a symlink to /private/tmp)
|
||||
local tmp_cleaned=0
|
||||
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"
|
||||
|
||||
# Clean system logs (macOS /var is a symlink to /private/var)
|
||||
safe_sudo_find_delete "/private/var/log" "*.log" "$MOLE_LOG_AGE_DAYS" "f" || true
|
||||
safe_sudo_find_delete "/private/var/log" "*.gz" "$MOLE_LOG_AGE_DAYS" "f" || true
|
||||
log_success "System logs"
|
||||
|
||||
# Clean Library Updates safely (skip if SIP is enabled)
|
||||
if [[ -d "/Library/Updates" && ! -L "/Library/Updates" ]]; then
|
||||
if ! is_sip_enabled; then
|
||||
@@ -41,14 +34,12 @@ clean_deep_system() {
|
||||
debug_log "Skipping malformed path: $item"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip system-protected files (restricted flag)
|
||||
local item_flags
|
||||
item_flags=$($STAT_BSD -f%Sf "$item" 2> /dev/null || echo "")
|
||||
if [[ "$item_flags" == *"restricted"* ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if safe_sudo_remove "$item"; then
|
||||
((updates_cleaned++))
|
||||
fi
|
||||
@@ -56,20 +47,16 @@ clean_deep_system() {
|
||||
[[ $updates_cleaned -gt 0 ]] && log_success "System library updates"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Clean macOS Install Data (legacy upgrade leftovers)
|
||||
if [[ -d "/macOS Install Data" ]]; then
|
||||
local mtime=$(get_file_mtime "/macOS Install Data")
|
||||
local age_days=$((($(date +%s) - mtime) / 86400))
|
||||
|
||||
debug_log "Found macOS Install Data (age: ${age_days} days)"
|
||||
|
||||
if [[ $age_days -ge 30 ]]; then
|
||||
local size_kb=$(get_path_size_kb "/macOS Install Data")
|
||||
if [[ -n "$size_kb" && "$size_kb" -gt 0 ]]; then
|
||||
local size_human=$(bytes_to_human "$((size_kb * 1024))")
|
||||
debug_log "Cleaning macOS Install Data: $size_human (${age_days} days old)"
|
||||
|
||||
if safe_sudo_remove "/macOS Install Data"; then
|
||||
log_success "macOS Install Data ($size_human)"
|
||||
fi
|
||||
@@ -78,21 +65,18 @@ clean_deep_system() {
|
||||
debug_log "Keeping macOS Install Data (only ${age_days} days old, needs 30+)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Clean browser code signature caches
|
||||
start_section_spinner "Scanning system caches..."
|
||||
local code_sign_cleaned=0
|
||||
local found_count=0
|
||||
local last_update_time=$(date +%s)
|
||||
local update_interval=2 # Update spinner every 2 seconds instead of every 50 files
|
||||
|
||||
# Efficient stream processing for large directories
|
||||
while IFS= read -r -d '' cache_dir; do
|
||||
if safe_remove "$cache_dir" true; then
|
||||
((code_sign_cleaned++))
|
||||
fi
|
||||
((found_count++))
|
||||
|
||||
# Update progress spinner periodically based on time, not count
|
||||
local current_time=$(date +%s)
|
||||
if [[ $((current_time - last_update_time)) -ge $update_interval ]]; then
|
||||
@@ -100,26 +84,20 @@ clean_deep_system() {
|
||||
last_update_time=$current_time
|
||||
fi
|
||||
done < <(run_with_timeout 5 command find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true)
|
||||
|
||||
stop_section_spinner
|
||||
|
||||
[[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches ($code_sign_cleaned items)"
|
||||
|
||||
# Clean system diagnostics logs
|
||||
safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
|
||||
safe_sudo_find_delete "/private/var/db/diagnostics/Persist" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
|
||||
safe_sudo_find_delete "/private/var/db/DiagnosticPipeline" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
|
||||
log_success "System diagnostic logs"
|
||||
|
||||
# Clean power logs
|
||||
safe_sudo_find_delete "/private/var/db/powerlog" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
|
||||
log_success "Power logs"
|
||||
|
||||
# Clean memory exception reports (can accumulate to 1-2GB, thousands of files)
|
||||
# These track app memory limit violations, safe to clean old ones
|
||||
safe_sudo_find_delete "/private/var/db/reportmemoryexception/MemoryLimitViolations" "*" "30" "f" || true
|
||||
log_success "Memory exception reports"
|
||||
|
||||
# Clean system diagnostic tracev3 logs (can be 1-2GB)
|
||||
# System generates these continuously, safe to clean old ones
|
||||
start_section_spinner "Cleaning diagnostic trace logs..."
|
||||
@@ -128,7 +106,6 @@ clean_deep_system() {
|
||||
safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*.tracev3" "30" "f" && diag_logs_cleaned=1 || true
|
||||
stop_section_spinner
|
||||
[[ $diag_logs_cleaned -eq 1 ]] && log_success "System diagnostic trace logs"
|
||||
|
||||
# Clean core symbolication cache (can be 3-5GB, mostly for crash report debugging)
|
||||
# Will regenerate when needed for crash analysis
|
||||
# Use faster du with timeout instead of get_path_size_kb to avoid hanging
|
||||
@@ -138,11 +115,9 @@ clean_deep_system() {
|
||||
# Quick size check with timeout (max 5 seconds)
|
||||
local symbolication_size_mb=""
|
||||
symbolication_size_mb=$(run_with_timeout 5 du -sm "/System/Library/Caches/com.apple.coresymbolicationd/data" 2> /dev/null | awk '{print $1}')
|
||||
|
||||
# Validate that we got a valid size (non-empty and numeric)
|
||||
if [[ -n "$symbolication_size_mb" && "$symbolication_size_mb" =~ ^[0-9]+$ ]]; then
|
||||
debug_log "Symbolication cache size: ${symbolication_size_mb}MB"
|
||||
|
||||
# Only clean if larger than 1GB (1024MB)
|
||||
if [[ $symbolication_size_mb -gt 1024 ]]; then
|
||||
debug_log "Cleaning symbolication cache (size > 1GB)..."
|
||||
@@ -156,21 +131,17 @@ clean_deep_system() {
|
||||
fi
|
||||
debug_log "Core symbolication cache section completed"
|
||||
}
|
||||
|
||||
# Clean 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")
|
||||
@@ -181,7 +152,6 @@ clean_time_machine_failed_backups() {
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! -d "/Volumes" ]]; then
|
||||
if [[ "$spinner_active" == "true" ]]; then
|
||||
stop_section_spinner
|
||||
@@ -189,7 +159,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
|
||||
@@ -198,25 +167,21 @@ 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)
|
||||
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
|
||||
@@ -225,7 +190,6 @@ 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..."
|
||||
@@ -237,47 +201,38 @@ clean_time_machine_failed_backups() {
|
||||
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)
|
||||
local file_mtime=$(get_file_mtime "$inprogress_file")
|
||||
local current_time=$(date +%s)
|
||||
local hours_old=$(((current_time - file_mtime) / 3600))
|
||||
|
||||
if [[ $hours_old -lt $MOLE_TM_BACKUP_SAFE_HOURS ]]; then
|
||||
continue
|
||||
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
|
||||
fi
|
||||
|
||||
local backup_name=$(basename "$inprogress_file")
|
||||
local size_human=$(bytes_to_human "$((size_kb * 1024))")
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete backup: $backup_name ${YELLOW}($size_human dry)${NC}"
|
||||
((tm_cleaned++))
|
||||
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
|
||||
fi
|
||||
|
||||
if tmutil delete "$inprogress_file" 2> /dev/null; then
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name ${GREEN}($size_human)${NC}"
|
||||
((tm_cleaned++))
|
||||
@@ -290,53 +245,42 @@ 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)
|
||||
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))
|
||||
|
||||
if [[ $hours_old -lt $MOLE_TM_BACKUP_SAFE_HOURS ]]; then
|
||||
continue
|
||||
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
|
||||
fi
|
||||
|
||||
local backup_name=$(basename "$inprogress_file")
|
||||
local size_human=$(bytes_to_human "$((size_kb * 1024))")
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete APFS backup in $bundle_name: $backup_name ${YELLOW}($size_human dry)${NC}"
|
||||
((tm_cleaned++))
|
||||
note_activity
|
||||
continue
|
||||
fi
|
||||
|
||||
# Real deletion
|
||||
if ! command -v tmutil > /dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if tmutil delete "$inprogress_file" 2> /dev/null; then
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name ${GREEN}($size_human)${NC}"
|
||||
((tm_cleaned++))
|
||||
@@ -351,55 +295,42 @@ 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
|
||||
|
||||
if [[ $tm_cleaned -eq 0 ]]; then
|
||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
|
||||
fi
|
||||
}
|
||||
|
||||
# Clean local APFS snapshots (older than 24 hours)
|
||||
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
|
||||
|
||||
# Get current time
|
||||
local current_ts=$(date +%s)
|
||||
local one_day_ago=$((current_ts - 86400))
|
||||
|
||||
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 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 snapshot is older than 24 hours
|
||||
if [[ $snap_ts -lt $one_day_ago ]]; then
|
||||
local snap_name="${BASH_REMATCH[0]}"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Old local snapshot: $snap_name ${YELLOW}(dry)${NC}"
|
||||
((cleaned_count++))
|
||||
@@ -417,7 +348,6 @@ clean_local_snapshots() {
|
||||
fi
|
||||
fi
|
||||
done <<< "$snapshot_list"
|
||||
|
||||
if [[ $cleaned_count -gt 0 && "$DRY_RUN" != "true" ]]; then
|
||||
log_success "Cleaned $cleaned_count old local snapshots"
|
||||
fi
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
#!/bin/bash
|
||||
# User Data Cleanup Module
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Clean user essentials (caches, logs, trash)
|
||||
clean_user_essentials() {
|
||||
start_section_spinner "Scanning caches..."
|
||||
|
||||
safe_clean ~/Library/Caches/* "User app cache"
|
||||
|
||||
stop_section_spinner
|
||||
|
||||
safe_clean ~/Library/Logs/* "User app logs"
|
||||
|
||||
# Check if Trash directory is whitelisted
|
||||
if is_path_whitelisted "$HOME/.Trash"; then
|
||||
note_activity
|
||||
@@ -21,33 +15,27 @@ clean_user_essentials() {
|
||||
safe_clean ~/.Trash/* "Trash"
|
||||
fi
|
||||
}
|
||||
|
||||
# Helper: Scan external volumes for cleanup (Trash & DS_Store)
|
||||
scan_external_volumes() {
|
||||
[[ -d "/Volumes" ]] || return 0
|
||||
|
||||
# Fast pre-check: collect non-system external volumes and detect network volumes
|
||||
local -a candidate_volumes=()
|
||||
local -a network_volumes=()
|
||||
for volume in /Volumes/*; do
|
||||
# Basic checks (directory, writable, not a symlink)
|
||||
[[ -d "$volume" && -w "$volume" && ! -L "$volume" ]] || continue
|
||||
|
||||
# Skip system root if it appears in /Volumes
|
||||
[[ "$volume" == "/" || "$volume" == "/Volumes/Macintosh HD" ]] && continue
|
||||
|
||||
# Use diskutil to intelligently detect network volumes (SMB/NFS/AFP)
|
||||
# Timeout protection: 1s per volume to avoid slow network responses
|
||||
local protocol=""
|
||||
protocol=$(run_with_timeout 1 command diskutil info "$volume" 2> /dev/null | grep -i "Protocol:" | awk '{print $2}' || echo "")
|
||||
|
||||
case "$protocol" in
|
||||
SMB | NFS | AFP | CIFS | WebDAV)
|
||||
network_volumes+=("$volume")
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
# Fallback: Check filesystem type via df if diskutil didn't identify protocol
|
||||
local fs_type=""
|
||||
fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "")
|
||||
@@ -57,14 +45,11 @@ scan_external_volumes() {
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
candidate_volumes+=("$volume")
|
||||
done
|
||||
|
||||
# If no external volumes found, return immediately (zero overhead)
|
||||
local volume_count=${#candidate_volumes[@]}
|
||||
local network_count=${#network_volumes[@]}
|
||||
|
||||
if [[ $volume_count -eq 0 ]]; then
|
||||
# Show info if network volumes were skipped
|
||||
if [[ $network_count -gt 0 ]]; then
|
||||
@@ -73,18 +58,14 @@ scan_external_volumes() {
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# We have local external volumes, now perform full scan
|
||||
start_section_spinner "Scanning $volume_count external volume(s)..."
|
||||
|
||||
for volume in "${candidate_volumes[@]}"; do
|
||||
# Re-verify volume is still accessible (may have been unmounted since initial scan)
|
||||
# Use simple directory check instead of slow mount command for better performance
|
||||
[[ -d "$volume" && -r "$volume" ]] || continue
|
||||
|
||||
# 1. Clean Trash on volume
|
||||
local volume_trash="$volume/.Trashes"
|
||||
|
||||
# Check if external volume Trash is whitelisted
|
||||
if [[ -d "$volume_trash" && "$DRY_RUN" != "true" ]] && ! is_path_whitelisted "$volume_trash"; then
|
||||
# Safely iterate and remove each item
|
||||
@@ -92,71 +73,56 @@ scan_external_volumes() {
|
||||
safe_remove "$item" true || true
|
||||
done < <(command find "$volume_trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
|
||||
fi
|
||||
|
||||
# 2. Clean .DS_Store
|
||||
if [[ "$PROTECT_FINDER_METADATA" != "true" ]]; then
|
||||
clean_ds_store_tree "$volume" "$(basename "$volume") volume (.DS_Store)"
|
||||
fi
|
||||
done
|
||||
|
||||
stop_section_spinner
|
||||
}
|
||||
|
||||
# Clean Finder metadata (.DS_Store files)
|
||||
clean_finder_metadata() {
|
||||
stop_section_spinner
|
||||
|
||||
if [[ "$PROTECT_FINDER_METADATA" == "true" ]]; then
|
||||
note_activity
|
||||
echo -e " ${GREEN}${ICON_EMPTY}${NC} Finder metadata · whitelist protected"
|
||||
return
|
||||
fi
|
||||
|
||||
clean_ds_store_tree "$HOME" "Home directory (.DS_Store)"
|
||||
}
|
||||
|
||||
# Clean macOS system caches
|
||||
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 ~/Library/Saved\ Application\ State/* "Saved application states"
|
||||
|
||||
safe_clean ~/Library/Saved\ Application\ State/* "Saved application states" || true
|
||||
# REMOVED: Spotlight cache cleanup can cause system UI issues
|
||||
# Spotlight indexes should be managed by macOS automatically
|
||||
# safe_clean ~/Library/Caches/com.apple.spotlight "Spotlight cache"
|
||||
|
||||
safe_clean ~/Library/Caches/com.apple.photoanalysisd "Photo analysis cache"
|
||||
safe_clean ~/Library/Caches/com.apple.akd "Apple ID cache"
|
||||
safe_clean ~/Library/Caches/com.apple.WebKit.Networking/* "WebKit network cache"
|
||||
|
||||
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"
|
||||
safe_clean ~/Library/Caches/com.apple.QuickLook.thumbnailcache "QuickLook thumbnails"
|
||||
safe_clean ~/Library/Caches/Quick\ Look/* "QuickLook cache"
|
||||
safe_clean ~/Library/Caches/com.apple.iconservices* "Icon services cache"
|
||||
safe_clean ~/Library/Caches/CloudKit/* "CloudKit cache"
|
||||
|
||||
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
|
||||
safe_clean ~/Library/Caches/com.apple.iconservices* "Icon services cache" || true
|
||||
safe_clean ~/Library/Caches/CloudKit/* "CloudKit cache" || true
|
||||
# Clean incomplete downloads
|
||||
safe_clean ~/Downloads/*.download "Safari incomplete downloads"
|
||||
safe_clean ~/Downloads/*.crdownload "Chrome incomplete downloads"
|
||||
safe_clean ~/Downloads/*.part "Partial incomplete downloads"
|
||||
|
||||
safe_clean ~/Downloads/*.download "Safari incomplete downloads" || true
|
||||
safe_clean ~/Downloads/*.crdownload "Chrome incomplete downloads" || true
|
||||
safe_clean ~/Downloads/*.part "Partial incomplete downloads" || true
|
||||
# Additional user-level caches
|
||||
safe_clean ~/Library/Autosave\ Information/* "Autosave information"
|
||||
safe_clean ~/Library/IdentityCaches/* "Identity caches"
|
||||
safe_clean ~/Library/Suggestions/* "Siri suggestions cache"
|
||||
safe_clean ~/Library/Calendars/Calendar\ Cache "Calendar cache"
|
||||
safe_clean ~/Library/Application\ Support/AddressBook/Sources/*/Photos.cache "Address Book photo cache"
|
||||
safe_clean ~/Library/Autosave\ Information/* "Autosave information" || true
|
||||
safe_clean ~/Library/IdentityCaches/* "Identity caches" || true
|
||||
safe_clean ~/Library/Suggestions/* "Siri suggestions cache" || true
|
||||
safe_clean ~/Library/Calendars/Calendar\ Cache "Calendar cache" || true
|
||||
safe_clean ~/Library/Application\ Support/AddressBook/Sources/*/Photos.cache "Address Book photo cache" || true
|
||||
}
|
||||
|
||||
# Clean recent items lists
|
||||
clean_recent_items() {
|
||||
stop_section_spinner
|
||||
|
||||
local shared_dir="$HOME/Library/Application Support/com.apple.sharedfilelist"
|
||||
|
||||
# Target only the global recent item lists to avoid touching per-app/System Settings SFL files
|
||||
local -a recent_lists=(
|
||||
"$shared_dir/com.apple.LSSharedFileList.RecentApplications.sfl2"
|
||||
@@ -168,53 +134,40 @@ clean_recent_items() {
|
||||
"$shared_dir/com.apple.LSSharedFileList.RecentServers.sfl"
|
||||
"$shared_dir/com.apple.LSSharedFileList.RecentHosts.sfl"
|
||||
)
|
||||
|
||||
if [[ -d "$shared_dir" ]]; then
|
||||
for sfl_file in "${recent_lists[@]}"; do
|
||||
[[ -e "$sfl_file" ]] && safe_clean "$sfl_file" "Recent items list"
|
||||
[[ -e "$sfl_file" ]] && safe_clean "$sfl_file" "Recent items list" || true
|
||||
done
|
||||
fi
|
||||
|
||||
# Clean recent items preferences
|
||||
safe_clean ~/Library/Preferences/com.apple.recentitems.plist "Recent items preferences"
|
||||
safe_clean ~/Library/Preferences/com.apple.recentitems.plist "Recent items preferences" || true
|
||||
}
|
||||
|
||||
# Clean old mail downloads
|
||||
clean_mail_downloads() {
|
||||
stop_section_spinner
|
||||
|
||||
local mail_age_days=${MOLE_MAIL_AGE_DAYS:-30}
|
||||
if ! [[ "$mail_age_days" =~ ^[0-9]+$ ]]; then
|
||||
mail_age_days=30
|
||||
fi
|
||||
|
||||
local -a mail_dirs=(
|
||||
"$HOME/Library/Mail Downloads"
|
||||
"$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads"
|
||||
)
|
||||
|
||||
local count=0
|
||||
local cleaned_kb=0
|
||||
|
||||
for target_path in "${mail_dirs[@]}"; do
|
||||
if [[ -d "$target_path" ]]; then
|
||||
# Check directory size threshold
|
||||
local dir_size_kb=0
|
||||
if command -v du > /dev/null 2>&1; then
|
||||
dir_size_kb=$(du -sk "$target_path" 2> /dev/null | awk 'NR==1{print $1}')
|
||||
dir_size_kb=${dir_size_kb:-0}
|
||||
fi
|
||||
|
||||
dir_size_kb=$(get_path_size_kb "$target_path")
|
||||
# Skip if below threshold
|
||||
if [[ $dir_size_kb -lt ${MOLE_MAIL_DOWNLOADS_MIN_KB:-5120} ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Find and remove files older than specified days
|
||||
while IFS= read -r -d '' file_path; do
|
||||
if [[ -f "$file_path" ]]; then
|
||||
local file_size_kb=$(du -sk "$file_path" 2> /dev/null | awk 'NR==1{print $1}')
|
||||
file_size_kb=${file_size_kb:-0}
|
||||
local file_size_kb=$(get_path_size_kb "$file_path")
|
||||
if safe_remove "$file_path" true; then
|
||||
((count++))
|
||||
((cleaned_kb += file_size_kb))
|
||||
@@ -223,47 +176,36 @@ clean_mail_downloads() {
|
||||
done < <(command find "$target_path" -type f -mtime +"$mail_age_days" -print0 2> /dev/null || true)
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $count -gt 0 ]]; then
|
||||
local cleaned_mb=$(echo "$cleaned_kb" | awk '{printf "%.1f", $1/1024}')
|
||||
local cleaned_mb=$(echo "$cleaned_kb" | awk '{printf "%.1f", $1/1024}' || echo "0.0")
|
||||
echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $count mail attachments (~${cleaned_mb}MB)"
|
||||
note_activity
|
||||
fi
|
||||
}
|
||||
|
||||
# Clean 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
|
||||
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
|
||||
local size_human=$(bytes_to_human "$((total_size * 1024))")
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
@@ -277,12 +219,10 @@ clean_sandboxed_app_caches() {
|
||||
note_activity
|
||||
fi
|
||||
}
|
||||
|
||||
# Process a single container cache directory (reduces nesting)
|
||||
process_container_cache() {
|
||||
local container_dir="$1"
|
||||
[[ -d "$container_dir" ]] || return 0
|
||||
|
||||
# Extract bundle ID and check protection status early
|
||||
local bundle_id=$(basename "$container_dir")
|
||||
if is_critical_system_component "$bundle_id"; then
|
||||
@@ -291,11 +231,9 @@ process_container_cache() {
|
||||
if should_protect_data "$bundle_id" || should_protect_data "$(echo "$bundle_id" | tr '[:upper:]' '[:lower:]')"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local cache_dir="$container_dir/Data/Library/Caches"
|
||||
# Check if dir exists and has content
|
||||
[[ -d "$cache_dir" ]] || return 0
|
||||
|
||||
# Fast check if empty using find (more efficient than ls)
|
||||
if find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then
|
||||
# Use global variables from caller for tracking
|
||||
@@ -303,35 +241,28 @@ process_container_cache() {
|
||||
((total_size += size))
|
||||
found_any=true
|
||||
((cleaned_count++))
|
||||
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
# Clean contents safely with local nullglob management
|
||||
local _ng_state
|
||||
_ng_state=$(shopt -p nullglob || true)
|
||||
shopt -s nullglob
|
||||
|
||||
for item in "$cache_dir"/*; do
|
||||
[[ -e "$item" ]] || continue
|
||||
safe_remove "$item" true || true
|
||||
done
|
||||
|
||||
eval "$_ng_state"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Clean browser caches (Safari, Chrome, Edge, Firefox, etc.)
|
||||
clean_browsers() {
|
||||
stop_section_spinner
|
||||
|
||||
safe_clean ~/Library/Caches/com.apple.Safari/* "Safari cache"
|
||||
|
||||
# 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"
|
||||
safe_clean ~/Library/Caches/Chromium/* "Chromium cache"
|
||||
|
||||
safe_clean ~/Library/Caches/com.microsoft.edgemac/* "Edge cache"
|
||||
safe_clean ~/Library/Caches/company.thebrowser.Browser/* "Arc cache"
|
||||
safe_clean ~/Library/Caches/company.thebrowser.dia/* "Dia cache"
|
||||
@@ -344,11 +275,9 @@ 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
|
||||
clean_cloud_storage() {
|
||||
stop_section_spinner
|
||||
|
||||
safe_clean ~/Library/Caches/com.dropbox.* "Dropbox cache"
|
||||
safe_clean ~/Library/Caches/com.getdropbox.dropbox "Dropbox cache"
|
||||
safe_clean ~/Library/Caches/com.google.GoogleDrive "Google Drive cache"
|
||||
@@ -357,11 +286,9 @@ 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
|
||||
clean_office_applications() {
|
||||
stop_section_spinner
|
||||
|
||||
safe_clean ~/Library/Caches/com.microsoft.Word "Microsoft Word cache"
|
||||
safe_clean ~/Library/Caches/com.microsoft.Excel "Microsoft Excel cache"
|
||||
safe_clean ~/Library/Caches/com.microsoft.Powerpoint "Microsoft PowerPoint cache"
|
||||
@@ -371,62 +298,48 @@ 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
|
||||
clean_virtualization_tools() {
|
||||
stop_section_spinner
|
||||
|
||||
safe_clean ~/Library/Caches/com.vmware.fusion "VMware Fusion cache"
|
||||
safe_clean ~/Library/Caches/com.parallels.* "Parallels cache"
|
||||
safe_clean ~/VirtualBox\ VMs/.cache "VirtualBox cache"
|
||||
safe_clean ~/.vagrant.d/tmp/* "Vagrant temporary files"
|
||||
}
|
||||
|
||||
# Clean Application Support logs and caches
|
||||
clean_application_support_logs() {
|
||||
stop_section_spinner
|
||||
|
||||
if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" > /dev/null 2>&1; then
|
||||
note_activity
|
||||
echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped: No permission to access Application Support"
|
||||
return 0
|
||||
fi
|
||||
|
||||
start_section_spinner "Scanning Application Support..."
|
||||
|
||||
local total_size=0
|
||||
local cleaned_count=0
|
||||
local found_any=false
|
||||
|
||||
# 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")
|
||||
local app_name_lower=$(echo "$app_name" | tr '[:upper:]' '[:lower:]')
|
||||
local is_protected=false
|
||||
|
||||
if should_protect_data "$app_name"; then
|
||||
is_protected=true
|
||||
elif should_protect_data "$app_name_lower"; then
|
||||
is_protected=true
|
||||
fi
|
||||
|
||||
if [[ "$is_protected" == "true" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if is_critical_system_component "$app_name"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
local -a start_candidates=("$app_dir/log" "$app_dir/logs" "$app_dir/activitylog" "$app_dir/Cache/Cache_Data" "$app_dir/Crashpad/completed")
|
||||
|
||||
for candidate in "${start_candidates[@]}"; do
|
||||
if [[ -d "$candidate" ]]; then
|
||||
if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then
|
||||
@@ -434,7 +347,6 @@ clean_application_support_logs() {
|
||||
((total_size += size))
|
||||
((cleaned_count++))
|
||||
found_any=true
|
||||
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
for item in "$candidate"/*; do
|
||||
[[ -e "$item" ]] || continue
|
||||
@@ -445,16 +357,13 @@ clean_application_support_logs() {
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# Clean Group Containers logs
|
||||
local known_group_containers=(
|
||||
"group.com.apple.contentdelivery"
|
||||
)
|
||||
|
||||
for container in "${known_group_containers[@]}"; do
|
||||
local container_path="$HOME/Library/Group Containers/$container"
|
||||
local -a gc_candidates=("$container_path/Logs" "$container_path/Library/Logs")
|
||||
|
||||
for candidate in "${gc_candidates[@]}"; do
|
||||
if [[ -d "$candidate" ]]; then
|
||||
if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then
|
||||
@@ -462,7 +371,6 @@ clean_application_support_logs() {
|
||||
((total_size += size))
|
||||
((cleaned_count++))
|
||||
found_any=true
|
||||
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
for item in "$candidate"/*; do
|
||||
[[ -e "$item" ]] || continue
|
||||
@@ -473,12 +381,9 @@ clean_application_support_logs() {
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# Restore nullglob to previous state
|
||||
eval "$_ng_state"
|
||||
|
||||
stop_section_spinner
|
||||
|
||||
if [[ "$found_any" == "true" ]]; then
|
||||
local size_human=$(bytes_to_human "$((total_size * 1024))")
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
@@ -492,7 +397,6 @@ clean_application_support_logs() {
|
||||
note_activity
|
||||
fi
|
||||
}
|
||||
|
||||
# Check and show iOS device backup info
|
||||
check_ios_device_backups() {
|
||||
local backup_dir="$HOME/Library/Application Support/MobileSync/Backup"
|
||||
@@ -508,15 +412,14 @@ check_ios_device_backups() {
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Clean Apple Silicon specific caches
|
||||
# Env: IS_M_SERIES
|
||||
# Clean Apple Silicon specific caches
|
||||
clean_apple_silicon_caches() {
|
||||
if [[ "${IS_M_SERIES:-false}" != "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
start_section "Apple Silicon updates"
|
||||
safe_clean /Library/Apple/usr/share/rosetta/rosetta_update_bundle "Rosetta 2 cache"
|
||||
safe_clean ~/Library/Caches/com.apple.rosetta.update "Rosetta 2 user cache"
|
||||
|
||||
@@ -30,8 +30,8 @@ readonly NC="${ESC}[0m"
|
||||
readonly ICON_CONFIRM="◎"
|
||||
readonly ICON_ADMIN="⚙"
|
||||
readonly ICON_SUCCESS="✓"
|
||||
readonly ICON_ERROR="☹︎"
|
||||
readonly ICON_WARNING="☺︎"
|
||||
readonly ICON_ERROR="☻"
|
||||
readonly ICON_WARNING="●"
|
||||
readonly ICON_EMPTY="○"
|
||||
readonly ICON_SOLID="●"
|
||||
readonly ICON_LIST="•"
|
||||
|
||||
@@ -97,10 +97,23 @@ safe_remove() {
|
||||
debug_log "Removing: $path"
|
||||
|
||||
# Perform the deletion
|
||||
if rm -rf "$path" 2> /dev/null; then # SAFE: safe_remove implementation
|
||||
# Use || to capture the exit code so set -e won't abort on rm failures
|
||||
local error_msg
|
||||
local rm_exit=0
|
||||
error_msg=$(rm -rf "$path" 2>&1) || rm_exit=$?
|
||||
|
||||
if [[ $rm_exit -eq 0 ]]; then
|
||||
return 0
|
||||
else
|
||||
[[ "$silent" != "true" ]] && log_error "Failed to remove: $path"
|
||||
# Check if it's a permission error
|
||||
if [[ "$error_msg" == *"Permission denied"* ]] || [[ "$error_msg" == *"Operation not permitted"* ]]; then
|
||||
MOLE_PERMISSION_DENIED_COUNT=${MOLE_PERMISSION_DENIED_COUNT:-0}
|
||||
MOLE_PERMISSION_DENIED_COUNT=$((MOLE_PERMISSION_DENIED_COUNT + 1))
|
||||
export MOLE_PERMISSION_DENIED_COUNT
|
||||
debug_log "Permission denied: $path (may need Full Disk Access)"
|
||||
else
|
||||
[[ "$silent" != "true" ]] && log_error "Failed to remove: $path"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -241,8 +254,10 @@ get_path_size_kb() {
|
||||
return
|
||||
}
|
||||
# Direct execution without timeout overhead - critical for performance in loops
|
||||
# Use || echo 0 to ensure failure in du (e.g. permission error) doesn't exit script under set -e
|
||||
# Pipefail would normally cause the pipeline to fail if du fails, but || handle catches it.
|
||||
local size
|
||||
size=$(command du -sk "$path" 2> /dev/null | awk '{print $1}')
|
||||
size=$(command du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0")
|
||||
echo "${size:-0}"
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ log_warning() {
|
||||
|
||||
# Log error message
|
||||
log_error() {
|
||||
echo -e "${RED}${ICON_ERROR}${NC} $1" >&2
|
||||
echo -e "${YELLOW}${ICON_ERROR}${NC} $1" >&2
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo "[$timestamp] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true
|
||||
if [[ "${MO_DEBUG:-}" == "1" ]]; then
|
||||
|
||||
107
lib/core/ui.sh
107
lib/core/ui.sh
@@ -249,28 +249,35 @@ show_menu_option() {
|
||||
|
||||
# Background spinner implementation
|
||||
INLINE_SPINNER_PID=""
|
||||
INLINE_SPINNER_STOP_FILE=""
|
||||
|
||||
start_inline_spinner() {
|
||||
stop_inline_spinner 2> /dev/null || true
|
||||
local message="$1"
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
(
|
||||
# Clean exit handler for spinner subprocess (invoked by trap)
|
||||
# shellcheck disable=SC2329
|
||||
cleanup_spinner() { exit 0; }
|
||||
trap cleanup_spinner TERM INT EXIT
|
||||
# Create unique stop flag file for this spinner instance
|
||||
INLINE_SPINNER_STOP_FILE="${TMPDIR:-/tmp}/mole_spinner_$$_$RANDOM.stop"
|
||||
|
||||
(
|
||||
local stop_file="$INLINE_SPINNER_STOP_FILE"
|
||||
local chars
|
||||
chars="$(mo_spinner_chars)"
|
||||
[[ -z "$chars" ]] && chars="|/-\\"
|
||||
local i=0
|
||||
while true; do
|
||||
|
||||
# Cooperative exit: check for stop file instead of relying on signals
|
||||
while [[ ! -f "$stop_file" ]]; do
|
||||
local c="${chars:$((i % ${#chars})):1}"
|
||||
# Output to stderr to avoid interfering with stdout
|
||||
printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$message" >&2 || exit 0
|
||||
printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$message" >&2 || break
|
||||
((i++))
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
# Clean up stop file before exiting
|
||||
rm -f "$stop_file" 2> /dev/null || true
|
||||
exit 0
|
||||
) &
|
||||
INLINE_SPINNER_PID=$!
|
||||
disown 2> /dev/null || true
|
||||
@@ -281,17 +288,30 @@ start_inline_spinner() {
|
||||
|
||||
stop_inline_spinner() {
|
||||
if [[ -n "$INLINE_SPINNER_PID" ]]; then
|
||||
# Try graceful TERM first, then force KILL if needed
|
||||
if kill -0 "$INLINE_SPINNER_PID" 2> /dev/null; then
|
||||
kill -TERM "$INLINE_SPINNER_PID" 2> /dev/null || true
|
||||
sleep 0.1 2> /dev/null || true
|
||||
# Force kill if still running
|
||||
if kill -0 "$INLINE_SPINNER_PID" 2> /dev/null; then
|
||||
kill -KILL "$INLINE_SPINNER_PID" 2> /dev/null || true
|
||||
fi
|
||||
# Cooperative stop: create stop file to signal spinner to exit
|
||||
if [[ -n "$INLINE_SPINNER_STOP_FILE" ]]; then
|
||||
touch "$INLINE_SPINNER_STOP_FILE" 2> /dev/null || true
|
||||
fi
|
||||
|
||||
# Wait briefly for cooperative exit
|
||||
local wait_count=0
|
||||
while kill -0 "$INLINE_SPINNER_PID" 2> /dev/null && [[ $wait_count -lt 5 ]]; do
|
||||
sleep 0.05 2> /dev/null || true
|
||||
((wait_count++))
|
||||
done
|
||||
|
||||
# Only use SIGKILL as last resort if process is stuck
|
||||
if kill -0 "$INLINE_SPINNER_PID" 2> /dev/null; then
|
||||
kill -KILL "$INLINE_SPINNER_PID" 2> /dev/null || true
|
||||
fi
|
||||
|
||||
wait "$INLINE_SPINNER_PID" 2> /dev/null || true
|
||||
|
||||
# Cleanup
|
||||
rm -f "$INLINE_SPINNER_STOP_FILE" 2> /dev/null || true
|
||||
INLINE_SPINNER_PID=""
|
||||
INLINE_SPINNER_STOP_FILE=""
|
||||
|
||||
# Clear the line - use \033[2K to clear entire line, not just to end
|
||||
[[ -t 1 ]] && printf "\r\033[2K" >&2 || true
|
||||
fi
|
||||
@@ -355,3 +375,60 @@ format_last_used_summary() {
|
||||
fi
|
||||
echo "$value"
|
||||
}
|
||||
|
||||
# Check if terminal has Full Disk Access
|
||||
# Returns 0 if FDA is granted, 1 if denied, 2 if unknown
|
||||
has_full_disk_access() {
|
||||
# Cache the result to avoid repeated checks
|
||||
if [[ -n "${MOLE_HAS_FDA:-}" ]]; then
|
||||
if [[ "$MOLE_HAS_FDA" == "1" ]]; then
|
||||
return 0
|
||||
elif [[ "$MOLE_HAS_FDA" == "unknown" ]]; then
|
||||
return 2
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Test access to protected directories that require FDA
|
||||
# Strategy: Try to access directories that are commonly protected
|
||||
# If ANY of them are accessible, we likely have FDA
|
||||
# If ALL fail, we definitely don't have FDA
|
||||
local -a protected_dirs=(
|
||||
"$HOME/Library/Safari/LocalStorage"
|
||||
"$HOME/Library/Mail/V10"
|
||||
"$HOME/Library/Messages/chat.db"
|
||||
)
|
||||
|
||||
local accessible_count=0
|
||||
local tested_count=0
|
||||
|
||||
for test_path in "${protected_dirs[@]}"; do
|
||||
# Only test when the protected path exists
|
||||
if [[ -e "$test_path" ]]; then
|
||||
tested_count=$((tested_count + 1))
|
||||
# Try to stat the ACTUAL protected path - this requires FDA
|
||||
if stat "$test_path" > /dev/null 2>&1; then
|
||||
accessible_count=$((accessible_count + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Three possible outcomes:
|
||||
# 1. tested_count = 0: Can't determine (test paths don't exist) → unknown
|
||||
# 2. tested_count > 0 && accessible_count > 0: Has FDA → yes
|
||||
# 3. tested_count > 0 && accessible_count = 0: No FDA → no
|
||||
if [[ $tested_count -eq 0 ]]; then
|
||||
# Can't determine - test paths don't exist, treat as unknown
|
||||
export MOLE_HAS_FDA="unknown"
|
||||
return 2
|
||||
elif [[ $accessible_count -gt 0 ]]; then
|
||||
# At least one path is accessible → has FDA
|
||||
export MOLE_HAS_FDA=1
|
||||
return 0
|
||||
else
|
||||
# Tested paths exist but not accessible → no FDA
|
||||
export MOLE_HAS_FDA=0
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user