diff --git a/GUIDE.md b/GUIDE.md index e05f783..5c970aa 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -88,13 +88,21 @@ mole clean --dry-run 这个命令只会**显示**哪些文件会被清理,**不会真的删除**。你可以先看看效果再决定。 +**管理白名单(保护重要缓存):** + +```bash +mole clean --whitelist +``` + +交互式选择哪些缓存不要删除,比如开发工具的大型缓存(Homebrew、Gradle 等)。 + **正式清理:** ```bash mole clean ``` -会清理系统缓存、日志、临时文件等,释放磁盘空间。 +会清理系统缓存、日志、临时文件等,释放磁盘空间。Mole 很安全,只删除可重新生成的文件。 ### 卸载应用(彻底删除) @@ -144,36 +152,28 @@ mole analyze ## 第五步:注意事项 -### 建议做的事 +### 使用建议 -- **第一次使用先用 `--dry-run` 预览**,看看会清理什么 -- **定期清理**,比如每个月或磁盘快满的时候 -- **卸载应用前确认**,避免误删正在使用的软件 +**推荐:** +- 第一次使用先 `--dry-run` 预览 +- 定期清理(每月一次或磁盘快满时) +- 有大型缓存可用 `--whitelist` 保护 -### 不要做的事 - -- 不要频繁清理(一周一次足够了) -- 不要删除系统应用(工具会自动保护,但还是要注意) -- 不要在运行重要程序时清理缓存 +**避免:** +- 频繁清理(一周一次就够了) +- 运行重要程序时清理 ### 安全保障 -Mole 有智能保护机制: +**Mole 只删除可重新生成的缓存和日志,不会删除:** +- 应用配置文件(.plist)- 你的设置会保留 +- 应用数据(Application Support)- 重要文档不受影响 +- 系统关键文件、IDE 数据、数据库等 -- 不会删除系统关键文件 -- 会跳过正在运行的应用 -- 清理前会显示即将删除的内容 -- 默认保护大型缓存(如 Playwright 浏览器、HuggingFace 模型等) - -如果你有其他需要保护的文件,可以添加到白名单: +**白名单保护:** 可以保护特定缓存不被删除 ```bash -# 查看默认保护的文件 -mole clean --whitelist - -# 添加自定义保护 -mkdir -p ~/.config/mole -echo '~/我的重要缓存/*' >> ~/.config/mole/whitelist +mole clean --whitelist # 交互式选择要保护的缓存 ``` --- @@ -192,7 +192,9 @@ echo '~/我的重要缓存/*' >> ~/.config/mole/whitelist ### 清理后能恢复吗? -一般的缓存文件清理后会自动重新生成,但应用卸载后无法恢复,请谨慎操作。 +不需要恢复!Mole 只删除缓存和日志,应用会自动重新生成,不影响使用。 + +**注意:** 应用卸载后无法恢复(但配置文件会保留) ### 多久清理一次比较好? diff --git a/README.md b/README.md index f9a02d6..6e83a21 100644 --- a/README.md +++ b/README.md @@ -44,18 +44,19 @@ brew install tw93/tap/mole mole # Interactive menu mole clean # System cleanup mole clean --dry-run # Preview mode +mole clean --whitelist # Manage protected caches mole uninstall # Uninstall apps mole analyze # Disk analyzer mole update # Update Mole mole --help # Show help ``` -> 💡 New to terminal? Check [小白使用指南](./GUIDE.md) · Homebrew users: `brew upgrade mole` to update -> ⚠️ **Recommended:** Always run `mole clean --dry-run` first to preview changes before cleanup +> 💡 New to terminal? Check [小白使用指南](./GUIDE.md) · Homebrew users: `brew upgrade mole` to update +> 💡 **Tip:** Run `mole clean --dry-run` to preview, or `mole clean --whitelist` to protect important caches before cleanup ## Features -### 🧹 Deep System Cleanup +### Deep System Cleanup ```bash $ mole clean @@ -83,17 +84,16 @@ $ mole clean ==================================================================== ``` -**Protect important files:** +**Whitelist Protection:** ```bash -# View default whitelist (Playwright browsers, HuggingFace models, etc.) -mole clean --whitelist +mole clean --whitelist # Interactive - select caches to protect -# Add custom protection -echo '~/my-important-cache/*' >> ~/.config/mole/whitelist +# Default: Playwright browsers, HuggingFace models (always protected) +# Or edit: ~/.config/mole/whitelist ``` -### 🗑️ Smart App Uninstaller +### Smart App Uninstaller ```bash $ mole uninstall @@ -119,7 +119,7 @@ Space freed: 12.8GB ==================================================================== ``` -### 📊 Disk Space Analyzer +### Disk Space Analyzer ```bash $ mole analyze @@ -146,15 +146,11 @@ Total: 156.8GB ## FAQ -1. **Will Mole delete important files?** - No. Mole has built-in protection for: - - System-critical files like Input Methods, Dock, and System Preferences - - User data from IDEs like JetBrains DataGrip and VS Code, plus database tools - - License data from paid apps like 1Password and Adobe products - - Large downloaded caches like Playwright browsers and HuggingFace models - - App settings are only removed when you explicitly uninstall the app -2. **Can I undo cleanup operations?** - Cache files are safe to delete and will regenerate automatically. For important data protection, use the whitelist feature via `mole clean --whitelist`. -3. **How often should I run cleanup?** - Once a month is sufficient. Run when disk space is low. -4. **Is it safe to use?** - Yes. **Always run `mole clean --dry-run` first** to preview what will be deleted before any action. +1. **Is Mole safe?** - Yes. Mole only deletes caches and logs (regenerable data). Never deletes app settings, user documents, or system files. Run `mole clean --dry-run` to preview before cleanup. + +2. **How often should I clean?** - Once a month, or when disk space is low. + +3. **Can I protect specific caches?** - Yes. Use `mole clean --whitelist` to select which caches to keep (Playwright browsers, HuggingFace models are protected by default). ## Support diff --git a/lib/common.sh b/lib/common.sh index eea2fa3..91b3f88 100755 --- a/lib/common.sh +++ b/lib/common.sh @@ -4,6 +4,12 @@ set -euo pipefail +# Prevent multiple sourcing +if [[ -n "${MOLE_COMMON_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_COMMON_LOADED=1 + # Color definitions (readonly for safety) readonly ESC=$'\033' readonly GREEN="${ESC}[0;32m" diff --git a/lib/paginated_menu.sh b/lib/paginated_menu.sh index 8ce3b32..e3c1255 100755 --- a/lib/paginated_menu.sh +++ b/lib/paginated_menu.sh @@ -31,6 +31,17 @@ paginated_multi_select() { selected[i]=false done + if [[ -n "${MOLE_PRESELECTED_INDICES:-}" ]]; then + local cleaned_preselect="${MOLE_PRESELECTED_INDICES//[[:space:]]/}" + local -a initial_indices=() + IFS=',' read -ra initial_indices <<< "$cleaned_preselect" + for idx in "${initial_indices[@]}"; do + if [[ "$idx" =~ ^[0-9]+$ && $idx -ge 0 && $idx -lt $total_items ]]; then + selected[idx]=true + fi + done + fi + # Cleanup function cleanup() { show_cursor @@ -184,13 +195,18 @@ EOF fi # Store result in global variable instead of returning via stdout - local result="" + local -a selected_indices=() for ((i = 0; i < total_items; i++)); do if [[ ${selected[i]} == true ]]; then - result="$result $i" + selected_indices+=("$i") fi done - local final_result="${result# }" + + local final_result="" + if [[ ${#selected_indices[@]} -gt 0 ]]; then + local IFS=',' + final_result="${selected_indices[*]}" + fi # Remove the trap to avoid cleanup on normal exit trap - EXIT INT TERM diff --git a/lib/whitelist_manager.sh b/lib/whitelist_manager.sh new file mode 100755 index 0000000..932b2fa --- /dev/null +++ b/lib/whitelist_manager.sh @@ -0,0 +1,441 @@ +#!/bin/bash +# Whitelist management functionality +# Shows actual files that would be deleted by dry-run + +set -euo pipefail + +# Get script directory and source dependencies +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" +source "$SCRIPT_DIR/paginated_menu.sh" + +# Config file path +WHITELIST_CONFIG="$HOME/.config/mole/whitelist" + +declare -a DEFAULT_WHITELIST_PATTERNS=( + "$HOME/Library/Caches/ms-playwright*" + "$HOME/.cache/huggingface*" +) + +patterns_equivalent() { + local first="${1/#~/$HOME}" + local second="${2/#~/$HOME}" + + # Only exact string match, no glob expansion + [[ "$first" == "$second" ]] && return 0 + return 1 +} + +is_default_pattern() { + local candidate="$1" + local default_pat + for default_pat in "${DEFAULT_WHITELIST_PATTERNS[@]}"; do + if patterns_equivalent "$candidate" "$default_pat"; then + return 0 + fi + done + return 1 +} + +# Run dry-run cleanup and collect what would be deleted +collect_files_to_be_cleaned() { + local clean_sh="$SCRIPT_DIR/../bin/clean.sh" + local -a items=() + + echo -e "${BLUE}🔍${NC} Scanning cache files..." + echo "" + + # Run clean.sh in dry-run mode + local temp_output=$(mktemp) + echo "" | bash "$clean_sh" --dry-run 2>&1 > "$temp_output" || true + + # Strip ANSI color codes for parsing + local temp_plain=$(mktemp) + sed $'s/\033\[[0-9;]*m//g' "$temp_output" > "$temp_plain" + + # Parse output: " → Description (size, dry)" + local pattern='^[[:space:]]*→[[:space:]]+([^(]+)(\([^)]+\))?[[:space:]]*\(([^,)]+),.*dry\)$' + while IFS= read -r line; do + if [[ "$line" =~ $pattern ]]; then + local description="${BASH_REMATCH[1]}" + local size="${BASH_REMATCH[3]}" + + description="${description#${description%%[![:space:]]*}}" + description="${description%${description##*[![:space:]]}}" + + [[ "$description" =~ ^Orphaned ]] && continue + + # Find corresponding path from clean.sh + local path="" + while IFS= read -r src_line; do + # Match: safe_clean "" + # Path may contain escaped spaces (\ ) + if [[ "$src_line" =~ safe_clean[[:space:]]+(.+)[[:space:]]+\"$description\" ]]; then + path="${BASH_REMATCH[1]}" + break + fi + done < "$clean_sh" + + path="${path/#\~/$HOME}" + [[ -z "$path" || "$path" =~ \$ ]] && continue + + items+=("$path|$description|$size") + fi + done < "$temp_plain" + + rm -f "$temp_output" "$temp_plain" + + # Return early if no items found + if [[ ${#items[@]} -eq 0 ]]; then + AVAILABLE_CACHE_ITEMS=() + return + fi + + # Remove duplicates + local -a unique_items=() + local -a seen_descriptions=() + + for item in "${items[@]}"; do + IFS='|' read -r path desc size <<< "$item" + local is_duplicate=false + if [[ ${#seen_descriptions[@]} -gt 0 ]]; then + for seen in "${seen_descriptions[@]}"; do + [[ "$desc" == "$seen" ]] && is_duplicate=true && break + done + fi + + if [[ "$is_duplicate" == "false" ]]; then + unique_items+=("$item") + seen_descriptions+=("$desc") + fi + done + + # Sort by size (largest first) + local -a sorted_items=() + if [[ ${#unique_items[@]} -gt 0 ]]; then + while IFS= read -r item; do + sorted_items+=("$item") + done < <( + for item in "${unique_items[@]}"; do + IFS='|' read -r path desc size <<< "$item" + local size_kb=0 + if [[ "$size" =~ ([0-9.]+)GB ]]; then + size_kb=$(echo "${BASH_REMATCH[1]}" | awk '{printf "%d", $1 * 1024 * 1024}') + elif [[ "$size" =~ ([0-9.]+)MB ]]; then + size_kb=$(echo "${BASH_REMATCH[1]}" | awk '{printf "%d", $1 * 1024}') + elif [[ "$size" =~ ([0-9.]+)KB ]]; then + size_kb=$(echo "${BASH_REMATCH[1]}" | awk '{printf "%d", $1}') + fi + printf "%010d|%s\n" "$size_kb" "$item" + done | sort -rn | cut -d'|' -f2- + ) + fi + + # Safe assignment for empty array + if [[ ${#sorted_items[@]} -gt 0 ]]; then + AVAILABLE_CACHE_ITEMS=("${sorted_items[@]}") + else + AVAILABLE_CACHE_ITEMS=() + fi +} + +declare -a AVAILABLE_CACHE_ITEMS=() + +load_whitelist() { + local -a patterns=() + + # Always include default patterns + patterns=("${DEFAULT_WHITELIST_PATTERNS[@]}") + + # Add user-defined patterns from config file + if [[ -f "$WHITELIST_CONFIG" ]]; then + while IFS= read -r line; do + line="${line#${line%%[![:space:]]*}}" + line="${line%${line##*[![:space:]]}}" + [[ -z "$line" || "$line" =~ ^# ]] && continue + patterns+=("$line") + done < "$WHITELIST_CONFIG" + fi + + if [[ ${#patterns[@]} -gt 0 ]]; then + local -a unique_patterns=() + for pattern in "${patterns[@]}"; do + local duplicate="false" + if [[ ${#unique_patterns[@]} -gt 0 ]]; then + for existing in "${unique_patterns[@]}"; do + if patterns_equivalent "$pattern" "$existing"; then + duplicate="true" + break + fi + done + fi + [[ "$duplicate" == "true" ]] && continue + unique_patterns+=("$pattern") + done + CURRENT_WHITELIST_PATTERNS=("${unique_patterns[@]}") + else + CURRENT_WHITELIST_PATTERNS=() + fi +} + +is_whitelisted() { + local pattern="$1" + local check_pattern="${pattern/#\~/$HOME}" + + if [[ ${#CURRENT_WHITELIST_PATTERNS[@]} -eq 0 ]]; then + return 1 + fi + + for existing in "${CURRENT_WHITELIST_PATTERNS[@]}"; do + local existing_expanded="${existing/#\~/$HOME}" + if [[ "$check_pattern" == "$existing_expanded" ]]; then + return 0 + fi + if [[ "$check_pattern" == $existing_expanded ]]; then + return 0 + fi + done + return 1 +} + +format_whitelist_item() { + local description="$1" size="$2" is_protected="$3" + local desc_display="$description" + [[ ${#description} -gt 40 ]] && desc_display="${description:0:37}..." + local size_display=$(printf "%-15s" "$size") + local status="" + [[ "$is_protected" == "true" ]] && status=" ${GREEN}[Protected]${NC}" + printf "%-40s %s%s" "$desc_display" "$size_display" "$status" +} + +# Get friendly description for a path pattern +get_description_for_pattern() { + local pattern="$1" + local desc="" + + # Hardcoded descriptions for common patterns + case "$pattern" in + *"ms-playwright"*) + echo "Playwright Browser" + return + ;; + *"huggingface"*) + echo "HuggingFace Model" + return + ;; + esac + + # Try to match with safe_clean in clean.sh + # Use fuzzy matching by removing trailing /* or * + local pattern_base="${pattern%/\*}" + pattern_base="${pattern_base%\*}" + + while IFS= read -r line; do + if [[ "$line" =~ safe_clean[[:space:]]+(.+)[[:space:]]+\"([^\"]+)\" ]]; then + local clean_path="${BASH_REMATCH[1]}" + local clean_desc="${BASH_REMATCH[2]}" + clean_path="${clean_path/#\~/$HOME}" + + # Remove trailing /* or * for comparison + local clean_base="${clean_path%/\*}" + clean_base="${clean_base%\*}" + + # Check if base paths match + if [[ "$pattern_base" == "$clean_base" || "$clean_path" == "$pattern" || "$pattern" == "$clean_path" ]]; then + echo "$clean_desc" + return + fi + fi + done < "$SCRIPT_DIR/../bin/clean.sh" + + # If no match found, return short path + echo "${pattern/#$HOME/~}" +} + +manage_whitelist() { + clear + echo "" + echo -e "${PURPLE}📋 Whitelist Manager${NC}" + echo "" + + # Load user-defined whitelist + CURRENT_WHITELIST_PATTERNS=() + load_whitelist + + echo "Select the cache files that need to be protected" + echo -e "${GRAY}Protected items are pre-selected. You can also edit ${WHITELIST_CONFIG} directly.${NC}" + echo "" + + collect_files_to_be_cleaned + + # Add items from config that are not in the scan results + local -a all_items=() + if [[ ${#AVAILABLE_CACHE_ITEMS[@]} -gt 0 ]]; then + all_items=("${AVAILABLE_CACHE_ITEMS[@]}") + fi + + # Add saved patterns that are not in scan results + if [[ ${#CURRENT_WHITELIST_PATTERNS[@]} -gt 0 ]]; then + for pattern in "${CURRENT_WHITELIST_PATTERNS[@]}"; do + local pattern_expanded="${pattern/#\~/$HOME}" + local found="false" + + if [[ ${#all_items[@]} -gt 0 ]]; then + for item in "${all_items[@]}"; do + IFS='|' read -r path _ _ <<< "$item" + if patterns_equivalent "$path" "$pattern_expanded"; then + found="true" + break + fi + done + fi + + if [[ "$found" == "false" ]]; then + local desc=$(get_description_for_pattern "$pattern_expanded") + all_items+=("$pattern_expanded|$desc|0B") + fi + done + fi + + if [[ ${#all_items[@]} -eq 0 ]]; then + echo -e "${GREEN}✨${NC} No cache files found - system is clean!" + echo "" + echo "Press any key to exit..." + read -n 1 -s + return 0 + fi + + # Update global array with all items + AVAILABLE_CACHE_ITEMS=("${all_items[@]}") + + echo -e "${GREEN}✓${NC} Found ${#AVAILABLE_CACHE_ITEMS[@]} items" + echo "" + + local -a menu_options=() + local -a preselected_indices=() + local index=0 + + for item in "${AVAILABLE_CACHE_ITEMS[@]}"; do + IFS='|' read -r path description size <<< "$item" + local is_protected="false" + if is_whitelisted "$path"; then + is_protected="true" + preselected_indices+=("$index") + fi + menu_options+=("$(format_whitelist_item "$description" "$size" "$is_protected")") + ((index++)) + done + + echo -e "${GRAY}↑↓ Navigate | Space Toggle | Enter Save | Q Quit${NC}" + + if [[ ${#preselected_indices[@]} -gt 0 ]]; then + local IFS=',' + MOLE_PRESELECTED_INDICES="${preselected_indices[*]}" + else + unset MOLE_PRESELECTED_INDICES + fi + + MOLE_SELECTION_RESULT="" + paginated_multi_select "Select items to protect" "${menu_options[@]}" + unset MOLE_PRESELECTED_INDICES + local exit_code=$? + + if [[ $exit_code -ne 0 ]]; then + echo "" + echo -e "${YELLOW}Cancelled${NC} - No changes made" + return 1 + fi + + local -a selected_indices=() + if [[ -n "$MOLE_SELECTION_RESULT" ]]; then + IFS=',' read -ra selected_indices <<< "$MOLE_SELECTION_RESULT" + fi + + save_whitelist "${selected_indices[@]}" +} + +save_whitelist() { + local -a selected_indices=("$@") + mkdir -p "$(dirname "$WHITELIST_CONFIG")" + + local -a selected_patterns=() + local selected_default_count=0 + local selected_custom_count=0 + + for idx in "${selected_indices[@]}"; do + if [[ $idx -ge 0 && $idx -lt ${#AVAILABLE_CACHE_ITEMS[@]} ]]; then + local item="${AVAILABLE_CACHE_ITEMS[$idx]}" + IFS='|' read -r path description size <<< "$item" + local portable_path="${path/#$HOME/~}" + + local duplicate="false" + if [[ ${#selected_patterns[@]} -gt 0 ]]; then + for existing in "${selected_patterns[@]}"; do + if patterns_equivalent "$portable_path" "$existing"; then + duplicate="true" + break + fi + done + fi + [[ "$duplicate" == "true" ]] && continue + + if is_default_pattern "$portable_path"; then + ((selected_default_count++)) + else + ((selected_custom_count++)) + fi + + selected_patterns+=("$portable_path") + fi + done + + cat > "$WHITELIST_CONFIG" << 'EOF' +# Mole Whitelist - Protected paths won't be deleted +# Default: Playwright browsers, HuggingFace models +EOF + + # Only save custom (non-default) patterns + local -a custom_patterns=() + for pattern in "${selected_patterns[@]}"; do + if ! is_default_pattern "$pattern"; then + custom_patterns+=("$pattern") + fi + done + + if [[ ${#custom_patterns[@]} -gt 0 ]]; then + printf '\n' >> "$WHITELIST_CONFIG" + for pattern in "${custom_patterns[@]}"; do + echo "$pattern" >> "$WHITELIST_CONFIG" + done + fi + + local total_count=${#selected_patterns[@]} + local -a summary_parts=() + if [[ $selected_default_count -gt 0 ]]; then + local default_label="default" + [[ $selected_default_count -ne 1 ]] && default_label+="s" + summary_parts+=("$selected_default_count $default_label") + fi + if [[ $selected_custom_count -gt 0 ]]; then + local custom_label="custom" + [[ $selected_custom_count -ne 1 ]] && custom_label+="s" + summary_parts+=("$selected_custom_count $custom_label") + fi + + local summary="" + if [[ ${#summary_parts[@]} -gt 0 ]]; then + summary=" (${summary_parts[0]}" + for ((i = 1; i < ${#summary_parts[@]}; i++)); do + summary+=", ${summary_parts[$i]}" + done + summary+=")" + fi + + echo "" + echo -e "${GREEN}✓${NC} Protected $total_count items${summary}" + echo -e "${GRAY}Config: ${WHITELIST_CONFIG}${NC}" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + manage_whitelist +fi diff --git a/mole b/mole index 2fe567e..f25e5e9 100755 --- a/mole +++ b/mole @@ -130,6 +130,7 @@ show_help() { printf " %s%-28s%s %s\n" "$GREEN" "mole" "$NC" "Interactive main menu" printf " %s%-28s%s %s\n" "$GREEN" "mole clean" "$NC" "Deeper system cleanup" printf " %s%-28s%s %s\n" "$GREEN" "mole clean --dry-run" "$NC" "Preview cleanup (no deletions)" + printf " %s%-28s%s %s\n" "$GREEN" "mole clean --whitelist" "$NC" "Manage protected caches" printf " %s%-28s%s %s\n" "$GREEN" "mole uninstall" "$NC" "Remove applications completely" printf " %s%-28s%s %s\n" "$GREEN" "mole analyze" "$NC" "Interactive disk space explorer" printf " %s%-28s%s %s\n" "$GREEN" "mole update" "$NC" "Update Mole to the latest version"