diff --git a/bin/clean.sh b/bin/clean.sh index ff8a154..3193304 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -130,26 +130,74 @@ safe_clean() { local total_size_bytes=0 local total_count=0 + # Optimized: skip size calculation for empty checks, just try to delete + # Size calculation is the slowest part - do it in parallel + local -a existing_paths=() for path in "${targets[@]}"; do - local size_bytes=0 - local count=0 - - if [[ -e "$path" ]]; then - size_bytes=$(du -sk "$path" 2>/dev/null | awk '{print $1}' || echo "0") - count=$(find "$path" -type f 2>/dev/null | wc -l | tr -d ' ') - - if [[ "$count" -eq 0 || "$size_bytes" -eq 0 ]]; then - continue - fi - - rm -rf "$path" 2>/dev/null || true - - ((total_size_bytes += size_bytes)) - ((total_count += count)) - removed_any=1 - fi + [[ -e "$path" ]] && existing_paths+=("$path") done + if [[ ${#existing_paths[@]} -eq 0 ]]; then + LAST_CLEAN_RESULT=0 + return 0 + fi + + # Fast parallel processing for multiple targets + if [[ ${#existing_paths[@]} -gt 3 ]]; then + local temp_dir=$(mktemp -d) + + # Launch parallel du jobs (bash 3.2 compatible) + local -a pids=() + for path in "${existing_paths[@]}"; do + ( + local size=$(du -sk "$path" 2>/dev/null | awk '{print $1}' || echo "0") + local count=$(find "$path" -type f 2>/dev/null | wc -l | tr -d ' ') + echo "$size $count" > "$temp_dir/$(echo -n "$path" | shasum -a 256 | cut -d' ' -f1)" + ) & + pids+=($!) + + # Limit to 15 parallel jobs (bash 3.2 compatible) + if (( ${#pids[@]} >= 15 )); then + wait "${pids[0]}" 2>/dev/null || true + pids=("${pids[@]:1}") + fi + done + + # Wait for remaining jobs + for pid in "${pids[@]}"; do + wait "$pid" 2>/dev/null || true + done + + # Collect results and delete + for path in "${existing_paths[@]}"; do + local hash=$(echo -n "$path" | shasum -a 256 | cut -d' ' -f1) + if [[ -f "$temp_dir/$hash" ]]; then + read -r size count < "$temp_dir/$hash" + if [[ "$count" -gt 0 && "$size" -gt 0 ]]; then + rm -rf "$path" 2>/dev/null || true + ((total_size_bytes += size)) + ((total_count += count)) + removed_any=1 + fi + fi + done + + rm -rf "$temp_dir" + else + # Serial processing for few targets (faster than parallel overhead) + for path in "${existing_paths[@]}"; do + local size_bytes=$(du -sk "$path" 2>/dev/null | awk '{print $1}' || echo "0") + local count=$(find "$path" -type f 2>/dev/null | wc -l | tr -d ' ') + + if [[ "$count" -gt 0 && "$size_bytes" -gt 0 ]]; then + rm -rf "$path" 2>/dev/null || true + ((total_size_bytes += size_bytes)) + ((total_count += count)) + removed_any=1 + fi + done + fi + # Only show output if something was actually cleaned if [[ $removed_any -eq 1 ]]; then local size_human @@ -178,7 +226,7 @@ safe_clean() { } start_cleanup() { - echo "Removing app caches, browser data, developer tools, and temporary files..." + echo "Mole will remove app caches, browser data, developer tools, and temporary files." echo "" # Check if we're in an interactive terminal @@ -191,13 +239,33 @@ start_cleanup() { else # Non-interactive mode - skip password prompt password="" - log_info "Running in non-interactive mode, skipping system-level cleanup." + echo "" + echo -e "${BLUE}ℹ${NC} Running in non-interactive mode" + echo " • System-level cleanup will be skipped (requires password)" + echo " • User-level cleanup will proceed automatically" + echo "" fi if [[ -n "$password" ]] && echo "$password" | sudo -S true 2>/dev/null; then SYSTEM_CLEAN=true - # Start sudo keepalive with shorter intervals for reliability - while true; do sudo -n true; sleep 30; kill -0 "$$" 2>/dev/null || exit; done 2>/dev/null & + # Start sudo keepalive with error handling and shorter intervals + ( + local retry_count=0 + while true; do + if ! sudo -n true 2>/dev/null; then + ((retry_count++)) + if [[ $retry_count -ge 3 ]]; then + log_warning "Sudo keepalive failed, system-level cleanup may be interrupted" >&2 + exit 1 + fi + sleep 5 + continue + fi + retry_count=0 + sleep 30 + kill -0 "$$" 2>/dev/null || exit + done + ) 2>/dev/null & SUDO_KEEPALIVE_PID=$! log_info "Starting comprehensive cleanup with admin privileges..." else diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 4606ea2..cde90a0 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -90,6 +90,30 @@ get_app_last_used() { # Scan applications and collect information scan_applications() { + # Cache configuration + local cache_dir="$HOME/.cache/mole" + local cache_file="$cache_dir/app_scan_cache" + local cache_meta="$cache_dir/app_scan_meta" + local cache_ttl=3600 # 1 hour cache validity + + mkdir -p "$cache_dir" 2>/dev/null + + # Quick count of current apps + local current_app_count=$(find /Applications -name "*.app" -maxdepth 1 2>/dev/null | wc -l | tr -d ' ') + + # Check if cache is valid + if [[ -f "$cache_file" && -f "$cache_meta" ]]; then + local cache_age=$(($(date +%s) - $(stat -f%m "$cache_file" 2>/dev/null || echo 0))) + local cached_app_count=$(cat "$cache_meta" 2>/dev/null || echo "0") + + # Cache is valid if: age < TTL AND app count matches + if [[ $cache_age -lt $cache_ttl && "$cached_app_count" == "$current_app_count" ]]; then + echo "Using cached app list (${cache_age}s old, $current_app_count apps) ✓" >&2 + echo "$cache_file" + return 0 + fi + fi + local temp_file=$(mktemp) echo -n "Scanning... " >&2 @@ -160,24 +184,9 @@ scan_applications() { # Select the first (best) candidate display_name="${candidates[0]:-$app_name}" - - # Brand name mapping for better user recognition (post-process) - case "$display_name" in - "qiyimac"|"爱奇艺") display_name="iQiyi" ;; - "wechat"|"微信") display_name="WeChat" ;; - "QQ"|"QQ") display_name="QQ" ;; - "VooV Meeting"|"腾讯会议") display_name="VooV Meeting" ;; - "dingtalk"|"钉钉") display_name="DingTalk" ;; - "NeteaseMusic"|"网易云音乐") display_name="NetEase Music" ;; - "BaiduNetdisk"|"百度网盘") display_name="Baidu NetDisk" ;; - "alipay"|"支付宝") display_name="Alipay" ;; - "taobao"|"淘宝") display_name="Taobao" ;; - "futunn"|"富途牛牛") display_name="Futu NiuNiu" ;; - "tencent lemon"|"Tencent Lemon Cleaner") display_name="Tencent Lemon" ;; - "keynote"|"Keynote") display_name="Keynote" ;; - "pages"|"Pages") display_name="Pages" ;; - "numbers"|"Numbers") display_name="Numbers" ;; - esac + + # Apply brand name mapping from common.sh + display_name="$(get_brand_name "$display_name")" fi # Skip protected system apps early @@ -189,20 +198,21 @@ scan_applications() { app_data_tuples+=("${app_path}|${app_name}|${bundle_id}|${display_name}") done < <(find /Applications -name "*.app" -maxdepth 1 -print0 2>/dev/null) - # Second pass: process each app with accurate size calculation + # Second pass: process each app with parallel size calculation local app_count=0 local total_apps=${#app_data_tuples[@]} + local max_parallel=10 # Process 10 apps in parallel + local pids=() + + # Process app metadata extraction function + process_app_metadata() { + local app_data_tuple="$1" + local output_file="$2" + local current_epoch="$3" - for app_data_tuple in "${app_data_tuples[@]}"; do IFS='|' read -r app_path app_name bundle_id display_name <<< "$app_data_tuple" - # Show progress every few items - ((app_count++)) - if (( app_count % 5 == 0 )) || [[ $app_count -eq $total_apps ]]; then - echo -ne "\rScanning... $app_count/$total_apps" >&2 - fi - - # Accurate size calculation - this is what takes time but user wants it + # Parallel size calculation local app_size="N/A" if [[ -d "$app_path" ]]; then app_size=$(du -sh "$app_path" 2>/dev/null | cut -f1 || echo "N/A") @@ -216,7 +226,6 @@ scan_applications() { local metadata_date=$(mdls -name kMDItemLastUsedDate -raw "$app_path" 2>/dev/null) if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then - # Convert macOS date format to epoch last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2>/dev/null || echo "0") if [[ $last_used_epoch -gt 0 ]]; then @@ -230,29 +239,17 @@ scan_applications() { last_used="${days_ago} days ago" elif [[ $days_ago -lt 30 ]]; then local weeks_ago=$(( days_ago / 7 )) - if [[ $weeks_ago -eq 1 ]]; then - last_used="1 week ago" - else - last_used="${weeks_ago} weeks ago" - fi + [[ $weeks_ago -eq 1 ]] && last_used="1 week ago" || last_used="${weeks_ago} weeks ago" elif [[ $days_ago -lt 365 ]]; then local months_ago=$(( days_ago / 30 )) - if [[ $months_ago -eq 1 ]]; then - last_used="1 month ago" - else - last_used="${months_ago} months ago" - fi + [[ $months_ago -eq 1 ]] && last_used="1 month ago" || last_used="${months_ago} months ago" else local years_ago=$(( days_ago / 365 )) - if [[ $years_ago -eq 1 ]]; then - last_used="1 year ago" - else - last_used="${years_ago} years ago" - fi + [[ $years_ago -eq 1 ]] && last_used="1 year ago" || last_used="${years_ago} years ago" fi fi else - # Fallback to file modification time if no usage metadata + # Fallback to file modification time last_used_epoch=$(stat -f%m "$app_path" 2>/dev/null || echo "0") if [[ $last_used_epoch -gt 0 ]]; then local days_ago=$(( (current_epoch - last_used_epoch) / 86400 )) @@ -267,8 +264,33 @@ scan_applications() { fi fi - # Format: epoch|app_path|display_name|bundle_id|size|last_used_display - echo "${last_used_epoch}|${app_path}|${display_name}|${bundle_id}|${app_size}|${last_used}" >> "$temp_file" + # Write to output file atomically + echo "${last_used_epoch}|${app_path}|${display_name}|${bundle_id}|${app_size}|${last_used}" >> "$output_file" + } + + export -f process_app_metadata + + # Process apps in parallel batches + for app_data_tuple in "${app_data_tuples[@]}"; do + ((app_count++)) + + # Launch background process + process_app_metadata "$app_data_tuple" "$temp_file" "$current_epoch" & + pids+=($!) + + # Update progress + echo -ne "\rScanning... $app_count/$total_apps" >&2 + + # Wait if we've hit max parallel limit + if (( ${#pids[@]} >= max_parallel )); then + wait "${pids[0]}" 2>/dev/null + pids=("${pids[@]:1}") # Remove first pid + fi + done + + # Wait for remaining background processes + for pid in "${pids[@]}"; do + wait "$pid" 2>/dev/null done echo -e "\rFound $app_count applications ✓" >&2 @@ -279,9 +301,13 @@ scan_applications() { return 1 fi - # Sort by last used (oldest first) and return the temp file path + # Sort by last used (oldest first) and cache the result sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" rm -f "$temp_file" + + # Update cache with app count metadata + cp "${temp_file}.sorted" "$cache_file" 2>/dev/null || true + echo "$current_app_count" > "$cache_meta" 2>/dev/null || true echo "${temp_file}.sorted" } @@ -479,7 +505,7 @@ main() { fi echo "" - # 直接执行批量卸载,确认已在批量卸载函数中处理 + # Execute batch uninstallation, confirmation handled in batch_uninstall_applications batch_uninstall_applications # Cleanup diff --git a/lib/app_selector.sh b/lib/app_selector.sh index ebc2664..cb9aeb0 100755 --- a/lib/app_selector.sh +++ b/lib/app_selector.sh @@ -40,6 +40,8 @@ select_apps_for_uninstall() { echo "" echo "🗑️ App Uninstaller" echo "" + echo "Mole will uninstall selected apps and clean all their related files." + echo "" echo "Found ${#apps_data[@]} apps. Select apps to remove:" echo "" diff --git a/lib/batch_uninstall.sh b/lib/batch_uninstall.sh index bbdfca6..773a46e 100755 --- a/lib/batch_uninstall.sh +++ b/lib/batch_uninstall.sh @@ -18,7 +18,8 @@ batch_uninstall_applications() { local total_estimated_size=0 local -a app_details=() - echo "📋 Analyzing selected applications..." + echo "" + echo -e "${BLUE}📋 Analyzing selected applications...${NC}" for selected_app in "${selected_apps[@]}"; do IFS='|' read -r epoch app_path app_name bundle_id size last_used <<< "$selected_app" @@ -51,22 +52,24 @@ batch_uninstall_applications() { # Show summary and get batch confirmation echo "" - echo "Will remove ${#selected_apps[@]} applications, free $size_display" + echo -e "${YELLOW}📦 Will remove ${BLUE}${#selected_apps[@]}${YELLOW} applications, free ${GREEN}$size_display${NC}" if [[ ${#running_apps[@]} -gt 0 ]]; then - echo "Running apps will be force-quit: ${running_apps[*]}" + echo -e "${YELLOW}⚠️ Running apps will be force-quit: ${RED}${running_apps[*]}${NC}" fi echo "" - read -p "Press ENTER to confirm, or any other key to cancel: " -r + echo -e -n "${BLUE}Press ENTER to confirm, or any other key to cancel:${NC} " + read -r if [[ -n "$REPLY" ]]; then log_info "Uninstallation cancelled by user" return 0 fi - echo "⚡ Starting uninstallation in 3 seconds... (Press Ctrl+C to abort)" - sleep 1 && echo "⚡ 2..." - sleep 1 && echo "⚡ 1..." + echo -e "${PURPLE}⚡ Starting uninstallation in 3 seconds...${NC} ${YELLOW}(Press Ctrl+C to abort)${NC}" + sleep 1 && echo -e "${PURPLE}⚡ ${BLUE}2${PURPLE}...${NC}" + sleep 1 && echo -e "${PURPLE}⚡ ${BLUE}1${PURPLE}...${NC}" sleep 1 + echo -e "${GREEN}✨ Let's go!${NC}" # Force quit running apps first (batch) if [[ ${#running_apps[@]} -gt 0 ]]; then @@ -92,7 +95,7 @@ batch_uninstall_applications() { # Decode the related files list local related_files=$(echo "$encoded_files" | base64 -d) - echo "🗑️ Uninstalling: $app_name" + echo -e "${YELLOW}🗑️ Uninstalling: ${BLUE}$app_name${NC}" # Remove the application if rm -rf "$app_path" 2>/dev/null; then @@ -127,24 +130,24 @@ batch_uninstall_applications() { echo "" echo "====================================================================" echo "🎉 UNINSTALLATION COMPLETE!" - + if [[ $success_count -gt 0 ]]; then if [[ $total_size_freed -gt 1048576 ]]; then - local freed_display=$(echo "$total_size_freed" | awk '{printf "%.1fGB", $1/1024/1024}') + local freed_display=$(echo "$total_size_freed" | awk '{printf "%.2fGB", $1/1024/1024}') elif [[ $total_size_freed -gt 1024 ]]; then local freed_display=$(echo "$total_size_freed" | awk '{printf "%.1fMB", $1/1024}') else local freed_display="${total_size_freed}KB" fi - echo "🗑️ Apps uninstalled: $success_count | Space freed: $freed_display" + echo "🗑️ Apps uninstalled: $success_count | Space freed: ${GREEN}${freed_display}${NC}" else echo "🗑️ No applications were uninstalled" fi - + if [[ $failed_count -gt 0 ]]; then - echo "⚠️ Failed to uninstall: $failed_count" + echo -e "${RED}⚠️ Failed to uninstall: $failed_count${NC}" fi - + echo "====================================================================" if [[ $failed_count -gt 0 ]]; then log_warning "$failed_count applications failed to uninstall" diff --git a/lib/common.sh b/lib/common.sh index 0b061cc..68cf8b6 100755 --- a/lib/common.sh +++ b/lib/common.sh @@ -29,7 +29,7 @@ log_info() { log_success() { rotate_log - echo -e "${GREEN}✅ $1${NC}" + echo -e " ${GREEN}✓${NC} $1" echo "[$(date '+%Y-%m-%d %H:%M:%S')] SUCCESS: $1" >> "$LOG_FILE" 2>/dev/null || true } @@ -116,7 +116,8 @@ read_key() { *) echo "OTHER" ;; esac else - echo "OTHER" + # ESC pressed alone - treat as quit + echo "QUIT" fi ;; *) echo "OTHER" ;; @@ -253,15 +254,22 @@ readonly PRESERVED_BUNDLE_PATTERNS=( ".GlobalPreferences" # Input methods (critical for international users) - "com.tencent.inputmethod.*" - "com.sogou.*" - "com.baidu.*" - "*.inputmethod.*" - "*input*" - "*inputmethod*" - "*InputMethod*" - "*ime*" - "*IME*" + # Specific input method bundles + "com.tencent.inputmethod.QQInput" + "com.sogou.inputmethod.*" + "com.baidu.inputmethod.*" + "com.apple.inputmethod.*" + "com.googlecode.rimeime.*" + "im.rime.*" + "org.pqrs.Karabiner*" + # Generic patterns (more conservative) + "*.inputmethod" + "*.InputMethod" + "*IME" + # Keep system input services safe + "com.apple.inputsource*" + "com.apple.TextInputMenuAgent" + "com.apple.TextInputSwitcher" # Cleanup and system tools (avoid infinite loops and preserve licenses) "com.nektony.*" # App Cleaner & Uninstaller @@ -382,3 +390,27 @@ calculate_total_size() { echo "$total_kb" } + +# Get normalized brand name (bash 3.2 compatible using case statement) +get_brand_name() { + local name="$1" + + # Brand name mapping for better user recognition + case "$name" in + "qiyimac"|"爱奇艺") echo "iQiyi" ;; + "wechat"|"微信") echo "WeChat" ;; + "QQ") echo "QQ" ;; + "VooV Meeting"|"腾讯会议") echo "VooV Meeting" ;; + "dingtalk"|"钉钉") echo "DingTalk" ;; + "NeteaseMusic"|"网易云音乐") echo "NetEase Music" ;; + "BaiduNetdisk"|"百度网盘") echo "Baidu NetDisk" ;; + "alipay"|"支付宝") echo "Alipay" ;; + "taobao"|"淘宝") echo "Taobao" ;; + "futunn"|"富途牛牛") echo "Futu NiuNiu" ;; + "tencent lemon"|"Tencent Lemon Cleaner") echo "Tencent Lemon" ;; + "keynote"|"Keynote") echo "Keynote" ;; + "pages"|"Pages") echo "Pages" ;; + "numbers"|"Numbers") echo "Numbers" ;; + *) echo "$name" ;; # Return original if no mapping found + esac +} diff --git a/mole b/mole index 3112753..93fe599 100755 --- a/mole +++ b/mole @@ -21,11 +21,72 @@ source "$SCRIPT_DIR/lib/common.sh" # Version info VERSION="1.1.0" -MOLE_TAGLINE="Dig deep like a mole to clean your Mac." +MOLE_TAGLINE="can dig deep to clean your Mac." + +# Check for updates (non-blocking, cached) +check_for_updates() { + local cache_dir="$HOME/.cache/mole" + local version_cache="$cache_dir/version_check" + local check_interval=86400 # Check once per day (24 hours) + + mkdir -p "$cache_dir" 2>/dev/null + + # Check if we should run version check (based on cache age) + if [[ -f "$version_cache" ]]; then + local cache_age=$(($(date +%s) - $(stat -f%m "$version_cache" 2>/dev/null || echo 0))) + if [[ $cache_age -lt $check_interval ]]; then + # Cache is still fresh, show cached message if exists + if [[ -s "$version_cache" ]]; then + cat "$version_cache" + fi + return 0 + fi + fi + + # Run version check in background (non-blocking) + ( + local latest_version="" + local timeout=3 # 3 second timeout for version check + + # Try to fetch latest version from GitHub with timeout + if command -v curl >/dev/null 2>&1; then + latest_version=$(curl -fsSL --connect-timeout 2 --max-time $timeout \ + "https://api.github.com/repos/tw93/mole/releases/latest" 2>/dev/null | \ + grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' | sed 's/^[Vv]//') + elif command -v wget >/dev/null 2>&1; then + latest_version=$(wget -qO- --timeout=$timeout --tries=1 \ + "https://api.github.com/repos/tw93/mole/releases/latest" 2>/dev/null | \ + grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' | sed 's/^[Vv]//') + fi + + # Compare versions if fetch succeeded + if [[ -n "$latest_version" && "$latest_version" != "$VERSION" ]]; then + # Version mismatch - cache the update message + local msg="${YELLOW}📢 New version available: ${GREEN}${latest_version}${YELLOW} (current: ${VERSION})${NC}\n Run ${GREEN}mole update${YELLOW} to upgrade${NC}" + echo -e "$msg" > "$version_cache" + echo -e "$msg" + else + # Up to date or check failed - clear cache + echo "" > "$version_cache" + fi + + # Touch cache file to update timestamp + touch "$version_cache" 2>/dev/null + ) & + + # Don't wait for background check + disown 2>/dev/null || true +} show_brand_banner() { - printf '%b🦡 Mole — %s%b\n' \ - "$GREEN" "$MOLE_TAGLINE" "$NC" + cat << EOF +${GREEN} __ __ _ ${NC} +${GREEN}| \/ | ___ | | ___ ${NC} +${GREEN}| |\/| |/ _ \| |/ _ \\${NC} +${GREEN}| | | | (_) | | __/${NC} ${BLUE}https://github.com/tw93/mole${NC} +${GREEN}|_| |_|\___/|_|\___|${NC} ${GREEN}${MOLE_TAGLINE}${NC} + +EOF } show_version() { @@ -58,17 +119,17 @@ update_mole() { local tmp_installer tmp_installer="$(mktemp)" || { log_error "Failed to create temp file"; exit 1; } - # Download installer + # Download installer with timeout if command -v curl >/dev/null 2>&1; then - if ! curl -fsSL "$installer_url" -o "$tmp_installer"; then + if ! curl -fsSL --connect-timeout 10 --max-time 60 "$installer_url" -o "$tmp_installer"; then rm -f "$tmp_installer" - log_error "Failed to download installer" + log_error "Failed to download installer (network timeout or error)" exit 1 fi elif command -v wget >/dev/null 2>&1; then - if ! wget -qO "$tmp_installer" "$installer_url"; then + if ! wget --timeout=10 --tries=3 -qO "$tmp_installer" "$installer_url"; then rm -f "$tmp_installer" - log_error "Failed to download installer" + log_error "Failed to download installer (network timeout or error)" exit 1 fi else @@ -183,6 +244,9 @@ interactive_main_menu() { } main() { + # Check for updates (non-blocking, won't delay startup) + check_for_updates + case "${1:-""}" in "clean") exec "$SCRIPT_DIR/bin/clean.sh"