diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index e21a792..5ef3658 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -40,10 +40,11 @@ Paste the debug output here ## Environment -- Mole version: (run `mo --version`) -- macOS version: (run `sw_vers`) -- Installation method: (Homebrew / curl script) -- Architecture: (Intel / Apple Silicon) +Please run `mo update` to ensure you are on the latest version, then paste the output of `mo --version` below: + +```text +Paste mo --version output here +``` ## Additional context diff --git a/README.md b/README.md index e2adb3f..b526c70 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

Mole

-

Dig deep like a mole to optimize your Mac.

+

Deep clean and optimize your Mac.

@@ -18,15 +18,15 @@ ## Features -- **All-in-one toolkit** equal to CleanMyMac + AppCleaner + DaisyDisk + Sensei + iStat in one **trusted binary** -- **Deep cleanup** finds and removes caches, temp files, browser leftovers, and junk to **free up tens of gigabytes** -- **Smart uninstall** finds app bundles plus launch agents, settings, caches, logs, and **leftover files** -- **Disk insight + optimization** show large files, display folders, **rebuild caches**, clean swap, refresh services -- **Live status** shows CPU, GPU, memory, disk, network, battery, and proxy data so you can **find problems** +- **All-in-one toolkit** combining the power of CleanMyMac, AppCleaner, DaisyDisk, Sensei, and iStat in one **trusted binary** +- **Deep cleanup** scans and removes caches, logs, browser leftovers, and junk to **reclaim tens of gigabytes** +- **Smart uninstall** completely removes apps including launch agents, preferences, caches, and **hidden leftovers** +- **Disk insight + optimization** visualizes usage, handles large files, **rebuilds caches**, cleans swap, and refreshes services +- **Live status** monitors CPU, GPU, memory, disk, network, battery, and proxy stats to **diagnose issues** ## Quick Start -**Install:** +**Installation:** ```bash curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash @@ -63,12 +63,12 @@ mo optimize --whitelist # Adjust protected optimization items ## Tips -- **Terminal**: iTerm2 has known compatibility issues, use Alacritty, kitty, WezTerm, Ghostty, or Warp instead -- **Safety**: Built with strict protections. See our [Security Audit](SECURITY_AUDIT.md). Preview with `mo clean --dry-run` -- **Whitelist**: Use `mo clean --whitelist` to manage protected caches -- **Touch ID**: Run `mo touchid` to approve sudo with Touch ID instead of password -- **Navigation**: All menus support Vim keys `h/j/k/l` in addition to arrow keys -- **Debug**: Use `--debug` flag to see detailed logs: `mo clean --debug` +- **Terminal**: iTerm2 has known compatibility issues; we recommend Alacritty, kitty, WezTerm, Ghostty, or Warp. +- **Safety**: Built with strict protections. See our [Security Audit](SECURITY_AUDIT.md). Preview changes with `mo clean --dry-run`. +- **Whitelist**: Manage protected paths with `mo clean --whitelist`. +- **Touch ID**: Enable Touch ID for sudo commands by running `mo touchid`. +- **Navigation**: Supports standard arrow keys and Vim bindings (`h/j/k/l`). +- **Debug**: View detailed logs by appending the `--debug` flag (e.g., `mo clean --debug`). ## Features in Detail @@ -195,10 +195,10 @@ Adds 5 commands: `clean`, `uninstall`, `optimize`, `analyze`, `status`. Finds yo -- If Mole freed storage for you, consider starring the repo or sharing it with friends needing a cleaner Mac. -- Have ideas or fixes? Open an issue or PR and help shape Mole's future together with the community. -- Report bugs by running commands with `--debug` flag and sharing the output: `mo clean --debug` -- Love cats? Treat Tangyuan and Cola to canned food via this link and keep the mascots purring. +- If Mole saved you space, consider starring the repo or sharing it with friends who need a cleaner Mac. +- Have ideas or fixes? Open an issue or PR to help shape Mole's future with the community. + +- Love cats? Treat Tangyuan and Cola to canned food via this link to keep our mascots purring. ## License diff --git a/bin/analyze-go b/bin/analyze-go index 1d1ab14..7f665e2 100755 Binary files a/bin/analyze-go and b/bin/analyze-go differ diff --git a/bin/clean.sh b/bin/clean.sh index 0cf8974..14c8d27 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -223,7 +223,7 @@ safe_clean() { # Hard-coded protection for critical apps (cannot be disabled by user) case "$path" in - *clash* | *Clash* | *surge* | *Surge* | *mihomo* | *openvpn* | *OpenVPN* | *verge* | *Verge* | *shadowsocks* | *Shadowsocks* | *v2ray* | *V2Ray* | *sing-box* | *tailscale* | *nordvpn* | *NordVPN* | *expressvpn* | *ExpressVPN* | *protonvpn* | *ProtonVPN* | *mullvad* | *Mullvad* | *hiddify* | *Hiddify* | *loon* | *Loon* | *Cursor* | *cursor* | *Claude* | *claude* | *ChatGPT* | *chatgpt* | *Ollama* | *ollama* | *lmstudio* | *Chatbox* | *Gemini* | *gemini* | *Perplexity* | *perplexity* | *Windsurf* | *windsurf* | *Poe* | *poe* | *DiffusionBee* | *diffusionbee* | *DrawThings* | *drawthings*) + *clash* | *Clash* | *surge* | *Surge* | *mihomo* | *openvpn* | *OpenVPN* | *verge* | *Verge* | *shadowsocks* | *Shadowsocks* | *v2ray* | *V2Ray* | *sing-box* | *tailscale* | *nordvpn* | *NordVPN* | *expressvpn* | *ExpressVPN* | *protonvpn* | *ProtonVPN* | *mullvad* | *Mullvad* | *hiddify* | *Hiddify* | *loon* | *Loon* | *Cursor* | *cursor* | *Claude* | *claude* | *ChatGPT* | *chatgpt* | *Ollama* | *ollama* | *lmstudio* | *Chatbox* | *Gemini* | *gemini* | *Perplexity* | *perplexity* | *Windsurf* | *windsurf* | *Poe* | *poe* | *DiffusionBee* | *diffusionbee* | *DrawThings* | *drawthings* | *Aerial* | *aerial* | *Fliqlo* | *fliqlo* | *com.apple.finder*) skip=true ((skipped_count++)) ;; diff --git a/bin/optimize.sh b/bin/optimize.sh index 3f474c9..8215962 100755 --- a/bin/optimize.sh +++ b/bin/optimize.sh @@ -99,9 +99,11 @@ show_optimization_summary() { fi summary_details+=("$summary_line4") - if [[ "${OPTIMIZE_SHOW_TOUCHID_TIP:-false}" == "true" ]]; then - echo -e "${YELLOW}☻${NC} Run ${GRAY}mo touchid${NC} to approve sudo via Touch ID" + if [[ -n "${AUTO_FIX_SUMMARY:-}" ]]; then + summary_details+=("$AUTO_FIX_SUMMARY") fi + + # Fix: Ensure summary is always printed for optimizations print_summary_block "$summary_title" "${summary_details[@]}" } @@ -245,6 +247,11 @@ collect_security_fix_actions() { SECURITY_FIXES+=("gatekeeper|Enable Gatekeeper (App download protection)") fi fi + if touchid_supported && ! touchid_configured; then + if ! is_whitelisted "touchid"; then + SECURITY_FIXES+=("touchid|Enable Touch ID for sudo") + fi + fi ((${#SECURITY_FIXES[@]} > 0)) } @@ -301,6 +308,13 @@ apply_gatekeeper_fix() { return 1 } +apply_touchid_fix() { + if "$SCRIPT_DIR/bin/touchid.sh" enable; then + return 0 + fi + return 1 +} + perform_security_fixes() { if ! ensure_sudo_session "Security changes require admin access"; then echo -e "${YELLOW}${ICON_WARNING}${NC} Skipped security fixes (sudo denied)" @@ -317,6 +331,9 @@ perform_security_fixes() { gatekeeper) apply_gatekeeper_fix && ((applied++)) ;; + touchid) + apply_touchid_fix && ((applied++)) + ;; esac done @@ -496,10 +513,6 @@ main() { export OPTIMIZE_SAFE_COUNT=$safe_count export OPTIMIZE_CONFIRM_COUNT=$confirm_count - export OPTIMIZE_SHOW_TOUCHID_TIP="false" - if touchid_supported && ! touchid_configured; then - export OPTIMIZE_SHOW_TOUCHID_TIP="true" - fi # Show optimization summary at the end show_optimization_summary diff --git a/bin/status-go b/bin/status-go index 3609d23..38e7510 100755 Binary files a/bin/status-go and b/bin/status-go differ diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 3343966..7a4fbdb 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -83,12 +83,24 @@ scan_applications() { [[ $cache_age -eq $(date +%s) ]] && cache_age=86401 # Handle missing file if [[ $cache_age -lt $cache_ttl ]]; then # Cache hit - return immediately + # Show brief flash of cache usage if in interactive mode + if [[ -t 2 ]]; then + echo -e "${GREEN}Loading from cache...${NC}" >&2 + # Small sleep to let user see it (optional, but good for "feeling" the speed vs glitch) + sleep 0.3 + fi echo "$cache_file" return 0 fi fi - # Cache miss - show scanning feedback below + # Cache miss - prepare for scanning + local inline_loading=false + if [[ -t 1 && -t 2 ]]; then + inline_loading=true + # Clear screen for inline loading + printf "\033[2J\033[H" >&2 + fi local temp_file temp_file=$(create_temp_file) @@ -97,11 +109,7 @@ scan_applications() { local current_epoch current_epoch=$(date "+%s") - # Spinner for scanning feedback (simple ASCII for compatibility) - local spinner_chars="|/-\\" - local spinner_idx=0 - - # First pass: quickly collect all valid app paths and bundle IDs + # First pass: quickly collect all valid app paths and bundle IDs (NO mdls calls) local -a app_data_tuples=() while IFS= read -r -d '' app_path; do if [[ ! -e "$app_path" ]]; then continue; fi @@ -118,78 +126,19 @@ scan_applications() { continue fi - # Try to get English name from bundle info, fallback to folder name + # Get bundle ID only (fast, no mdls calls in first pass) local bundle_id="unknown" - local display_name="$app_name" if [[ -f "$app_path/Contents/Info.plist" ]]; then bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown") - - # Try to get English name from bundle info - local bundle_executable - bundle_executable=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null) - - # Smart display name selection - prefer descriptive names over generic ones - local candidates=() - - # Get all potential names - local bundle_display_name - bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null) - local bundle_name - bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null) - - # Check if executable name is generic/technical (should be avoided) - local is_generic_executable=false - if [[ -n "$bundle_executable" ]]; then - case "$bundle_executable" in - "pake" | "Electron" | "electron" | "nwjs" | "node" | "helper" | "main" | "app" | "binary") - is_generic_executable=true - ;; - esac - fi - - # Priority order for name selection: - # 1. App folder name (if ASCII and descriptive) - often the most complete name - if [[ "$app_name" =~ ^[A-Za-z0-9\ ._-]+$ && ${#app_name} -gt 3 ]]; then - candidates+=("$app_name") - fi - - # 2. CFBundleDisplayName (if meaningful and ASCII) - if [[ -n "$bundle_display_name" && "$bundle_display_name" =~ ^[A-Za-z0-9\ ._-]+$ ]]; then - candidates+=("$bundle_display_name") - fi - - # 3. CFBundleName (if meaningful and ASCII) - if [[ -n "$bundle_name" && "$bundle_name" =~ ^[A-Za-z0-9\ ._-]+$ && "$bundle_name" != "$bundle_display_name" ]]; then - candidates+=("$bundle_name") - fi - - # 4. CFBundleExecutable (only if not generic and ASCII) - if [[ -n "$bundle_executable" && "$bundle_executable" =~ ^[A-Za-z0-9._-]+$ && "$is_generic_executable" == false ]]; then - candidates+=("$bundle_executable") - fi - - # 5. Fallback to non-ASCII names if no ASCII found - if [[ ${#candidates[@]} -eq 0 ]]; then - [[ -n "$bundle_display_name" ]] && candidates+=("$bundle_display_name") - [[ -n "$bundle_name" && "$bundle_name" != "$bundle_display_name" ]] && candidates+=("$bundle_name") - candidates+=("$app_name") - fi - - # Select the first (best) candidate - display_name="${candidates[0]:-$app_name}" - - # Apply brand name mapping from common.sh - display_name="$(get_brand_name "$display_name")" fi # Skip system critical apps (input methods, system components) - # Note: Paid apps like CleanMyMac, 1Password are NOT protected here - users can uninstall them if should_protect_from_uninstall "$bundle_id"; then continue fi - # Store tuple: app_path|app_name|bundle_id|display_name - app_data_tuples+=("${app_path}|${app_name}|${bundle_id}|${display_name}") + # Store tuple: app_path|app_name|bundle_id (display_name will be resolved in parallel later) + app_data_tuples+=("${app_path}|${app_name}|${bundle_id}") done < <( # Scan both system and user application directories # Using maxdepth 3 to find apps in subdirectories (e.g., Adobe apps in /Applications/Adobe X/) @@ -209,11 +158,7 @@ scan_applications() { max_parallel=32 fi local pids=() - local inline_loading=false - if [[ "${MOLE_INLINE_LOADING:-}" == "1" || "${MOLE_INLINE_LOADING:-}" == "true" ]]; then - inline_loading=true - printf "\033[H" >&2 # Position cursor at top of screen - fi + # inline_loading variable already set above (line ~92) # Process app metadata extraction function process_app_metadata() { @@ -221,7 +166,35 @@ scan_applications() { local output_file="$2" local current_epoch="$3" - IFS='|' read -r app_path app_name bundle_id display_name <<< "$app_data_tuple" + IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple" + + # Get localized display name (moved from first pass for better performance) + local display_name="$app_name" + if [[ -f "$app_path/Contents/Info.plist" ]]; then + # Try to get localized name from system metadata (best for i18n) + local md_display_name + md_display_name=$(run_with_timeout 0.05 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") + + # Get bundle names + local bundle_display_name + bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null) + local bundle_name + bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null) + + # Priority order for name selection (prefer localized names): + # 1. System metadata display name (kMDItemDisplayName) - respects system language + # 2. CFBundleDisplayName - usually localized + # 3. CFBundleName - fallback + # 4. App folder name - last resort + + if [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then + display_name="$md_display_name" + elif [[ -n "$bundle_display_name" && "$bundle_display_name" != "(null)" ]]; then + display_name="$bundle_display_name" + elif [[ -n "$bundle_name" && "$bundle_name" != "(null)" ]]; then + display_name="$bundle_name" + fi + fi # Parallel size calculation local app_size="N/A" @@ -293,9 +266,9 @@ scan_applications() { local completed=$(cat "$progress_file" 2> /dev/null || echo 0) local c="${spinner_chars:$((i % 4)):1}" if [[ $inline_loading == true ]]; then - printf "\033[H\033[2K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 + printf "\033[H\033[2K%s Scanning applications... %d/%d\n" "$c" "$completed" "$total_apps" >&2 else - echo -ne "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 + printf "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 fi ((i++)) sleep 0.1 2> /dev/null || sleep 1 @@ -346,12 +319,30 @@ scan_applications() { fi # Sort by last used (oldest first) and cache the result + # Show brief processing message for large app lists + if [[ $total_apps -gt 50 ]]; then + if [[ $inline_loading == true ]]; then + printf "\033[H\033[2KProcessing %d applications...\n" "$total_apps" >&2 + else + printf "\rProcessing %d applications... " "$total_apps" >&2 + fi + fi + sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || { rm -f "$temp_file" return 1 } rm -f "$temp_file" + # Clear processing message + if [[ $total_apps -gt 50 ]]; then + if [[ $inline_loading == true ]]; then + printf "\033[H\033[2K" >&2 + else + printf "\r\033[K" >&2 + fi + fi + # Save to cache (simplified - no metadata) cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true @@ -555,18 +546,22 @@ main() { # Show selected apps with clean alignment echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} app(s):" local -a summary_rows=() - local max_name_width=0 + local max_name_display_width=0 local max_size_width=0 local name_trunc_limit=30 for selected_app in "${selected_apps[@]}"; do IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$selected_app" - local display_name="$app_name" - if [[ ${#display_name} -gt $name_trunc_limit ]]; then - display_name="${display_name:0:$((name_trunc_limit - 3))}..." - fi - [[ ${#display_name} -gt $max_name_width ]] && max_name_width=${#display_name} + # Truncate by display width if needed + local display_name + display_name=$(truncate_by_display_width "$app_name" "$name_trunc_limit") + + # Get actual display width + local current_width + current_width=$(get_display_width "$display_name") + + [[ $current_width -gt $max_name_display_width ]] && max_name_display_width=$current_width local size_display="$size" if [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]]; then @@ -580,13 +575,20 @@ main() { summary_rows+=("$display_name|$size_display|$last_display") done - ((max_name_width < 16)) && max_name_width=16 + ((max_name_display_width < 16)) && max_name_display_width=16 ((max_size_width < 5)) && max_size_width=5 local index=1 for row in "${summary_rows[@]}"; do IFS='|' read -r name_cell size_cell last_cell <<< "$row" - printf "%d. %-*s %*s | Last: %s\n" "$index" "$max_name_width" "$name_cell" "$max_size_width" "$size_cell" "$last_cell" + # Calculate printf width based on actual display width + local name_display_width + name_display_width=$(get_display_width "$name_cell") + local name_char_count=${#name_cell} + local padding_needed=$((max_name_display_width - name_display_width)) + local printf_name_width=$((name_char_count + padding_needed)) + + printf "%d. %-*s %*s | Last: %s\n" "$index" "$printf_name_width" "$name_cell" "$max_size_width" "$size_cell" "$last_cell" ((index++)) done @@ -597,22 +599,20 @@ main() { rm -f "$apps_file" # Pause before looping back - echo -e "${GRAY}Press Enter to return to application list, ESC to exit...${NC}" + echo -e "${GRAY}Press Enter to return to application list, any other key to exit...${NC}" local key IFS= read -r -s -n1 key || key="" - drain_pending_input # Clean up any escape sequence remnants - case "$key" in - $'\e' | q | Q) - show_cursor - return 0 - ;; - *) - # Continue loop - ;; - esac + drain_pending_input - # Reset force_rescan to false for subsequent loops, - # but relying on batch_uninstall's cache deletion for actual update + # Logic: Enter = continue loop, any other key = exit + if [[ -z "$key" ]]; then + : # Enter pressed, continue loop + else + show_cursor + return 0 + fi + + # Reset force_rescan to false for subsequent loops force_rescan=false done } diff --git a/bin/uninstall_lib.sh b/bin/uninstall_lib.sh index 998ba1e..b0c9f61 100755 --- a/bin/uninstall_lib.sh +++ b/bin/uninstall_lib.sh @@ -83,12 +83,24 @@ scan_applications() { [[ $cache_age -eq $(date +%s) ]] && cache_age=86401 # Handle missing file if [[ $cache_age -lt $cache_ttl ]]; then # Cache hit - return immediately + # Show brief flash of cache usage if in interactive mode + if [[ -t 2 ]]; then + echo -e "${GREEN}Loading from cache...${NC}" >&2 + # Small sleep to let user see it (optional, but good for "feeling" the speed vs glitch) + sleep 0.3 + fi echo "$cache_file" return 0 fi fi - # Cache miss - show scanning feedback below + # Cache miss - prepare for scanning + local inline_loading=false + if [[ -t 1 && -t 2 ]]; then + inline_loading=true + # Clear screen for inline loading + printf "\033[2J\033[H" >&2 + fi local temp_file temp_file=$(create_temp_file) @@ -97,11 +109,7 @@ scan_applications() { local current_epoch current_epoch=$(date "+%s") - # Spinner for scanning feedback (simple ASCII for compatibility) - local spinner_chars="|/-\\" - local spinner_idx=0 - - # First pass: quickly collect all valid app paths and bundle IDs + # First pass: quickly collect all valid app paths and bundle IDs (NO mdls calls) local -a app_data_tuples=() while IFS= read -r -d '' app_path; do if [[ ! -e "$app_path" ]]; then continue; fi @@ -118,78 +126,19 @@ scan_applications() { continue fi - # Try to get English name from bundle info, fallback to folder name + # Get bundle ID only (fast, no mdls calls in first pass) local bundle_id="unknown" - local display_name="$app_name" if [[ -f "$app_path/Contents/Info.plist" ]]; then bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown") - - # Try to get English name from bundle info - local bundle_executable - bundle_executable=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null) - - # Smart display name selection - prefer descriptive names over generic ones - local candidates=() - - # Get all potential names - local bundle_display_name - bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null) - local bundle_name - bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null) - - # Check if executable name is generic/technical (should be avoided) - local is_generic_executable=false - if [[ -n "$bundle_executable" ]]; then - case "$bundle_executable" in - "pake" | "Electron" | "electron" | "nwjs" | "node" | "helper" | "main" | "app" | "binary") - is_generic_executable=true - ;; - esac - fi - - # Priority order for name selection: - # 1. App folder name (if ASCII and descriptive) - often the most complete name - if [[ "$app_name" =~ ^[A-Za-z0-9\ ._-]+$ && ${#app_name} -gt 3 ]]; then - candidates+=("$app_name") - fi - - # 2. CFBundleDisplayName (if meaningful and ASCII) - if [[ -n "$bundle_display_name" && "$bundle_display_name" =~ ^[A-Za-z0-9\ ._-]+$ ]]; then - candidates+=("$bundle_display_name") - fi - - # 3. CFBundleName (if meaningful and ASCII) - if [[ -n "$bundle_name" && "$bundle_name" =~ ^[A-Za-z0-9\ ._-]+$ && "$bundle_name" != "$bundle_display_name" ]]; then - candidates+=("$bundle_name") - fi - - # 4. CFBundleExecutable (only if not generic and ASCII) - if [[ -n "$bundle_executable" && "$bundle_executable" =~ ^[A-Za-z0-9._-]+$ && "$is_generic_executable" == false ]]; then - candidates+=("$bundle_executable") - fi - - # 5. Fallback to non-ASCII names if no ASCII found - if [[ ${#candidates[@]} -eq 0 ]]; then - [[ -n "$bundle_display_name" ]] && candidates+=("$bundle_display_name") - [[ -n "$bundle_name" && "$bundle_name" != "$bundle_display_name" ]] && candidates+=("$bundle_name") - candidates+=("$app_name") - fi - - # Select the first (best) candidate - display_name="${candidates[0]:-$app_name}" - - # Apply brand name mapping from common.sh - display_name="$(get_brand_name "$display_name")" fi # Skip system critical apps (input methods, system components) - # Note: Paid apps like CleanMyMac, 1Password are NOT protected here - users can uninstall them if should_protect_from_uninstall "$bundle_id"; then continue fi - # Store tuple: app_path|app_name|bundle_id|display_name - app_data_tuples+=("${app_path}|${app_name}|${bundle_id}|${display_name}") + # Store tuple: app_path|app_name|bundle_id (display_name will be resolved in parallel later) + app_data_tuples+=("${app_path}|${app_name}|${bundle_id}") done < <( # Scan both system and user application directories # Using maxdepth 3 to find apps in subdirectories (e.g., Adobe apps in /Applications/Adobe X/) @@ -209,11 +158,7 @@ scan_applications() { max_parallel=32 fi local pids=() - local inline_loading=false - if [[ "${MOLE_INLINE_LOADING:-}" == "1" || "${MOLE_INLINE_LOADING:-}" == "true" ]]; then - inline_loading=true - printf "\033[H" >&2 # Position cursor at top of screen - fi + # inline_loading variable already set above (line ~92) # Process app metadata extraction function process_app_metadata() { @@ -221,7 +166,35 @@ scan_applications() { local output_file="$2" local current_epoch="$3" - IFS='|' read -r app_path app_name bundle_id display_name <<< "$app_data_tuple" + IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple" + + # Get localized display name (moved from first pass for better performance) + local display_name="$app_name" + if [[ -f "$app_path/Contents/Info.plist" ]]; then + # Try to get localized name from system metadata (best for i18n) + local md_display_name + md_display_name=$(run_with_timeout 0.05 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") + + # Get bundle names + local bundle_display_name + bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null) + local bundle_name + bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null) + + # Priority order for name selection (prefer localized names): + # 1. System metadata display name (kMDItemDisplayName) - respects system language + # 2. CFBundleDisplayName - usually localized + # 3. CFBundleName - fallback + # 4. App folder name - last resort + + if [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then + display_name="$md_display_name" + elif [[ -n "$bundle_display_name" && "$bundle_display_name" != "(null)" ]]; then + display_name="$bundle_display_name" + elif [[ -n "$bundle_name" && "$bundle_name" != "(null)" ]]; then + display_name="$bundle_name" + fi + fi # Parallel size calculation local app_size="N/A" @@ -293,9 +266,9 @@ scan_applications() { local completed=$(cat "$progress_file" 2> /dev/null || echo 0) local c="${spinner_chars:$((i % 4)):1}" if [[ $inline_loading == true ]]; then - printf "\033[H\033[2K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 + printf "\033[H\033[2K%s Scanning applications... %d/%d\n" "$c" "$completed" "$total_apps" >&2 else - echo -ne "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 + printf "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 fi ((i++)) sleep 0.1 2> /dev/null || sleep 1 @@ -346,12 +319,30 @@ scan_applications() { fi # Sort by last used (oldest first) and cache the result + # Show brief processing message for large app lists + if [[ $total_apps -gt 50 ]]; then + if [[ $inline_loading == true ]]; then + printf "\033[H\033[2KProcessing %d applications...\n" "$total_apps" >&2 + else + printf "\rProcessing %d applications... " "$total_apps" >&2 + fi + fi + sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || { rm -f "$temp_file" return 1 } rm -f "$temp_file" + # Clear processing message + if [[ $total_apps -gt 50 ]]; then + if [[ $inline_loading == true ]]; then + printf "\033[H\033[2K" >&2 + else + printf "\r\033[K" >&2 + fi + fi + # Save to cache (simplified - no metadata) cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true diff --git a/lib/check/all.sh b/lib/check/all.sh index 09e2be2..c419a68 100644 --- a/lib/check/all.sh +++ b/lib/check/all.sh @@ -275,98 +275,37 @@ SOFTWARE_UPDATE_LIST="" get_software_updates() { local cache_file="$CACHE_DIR/softwareupdate_list" - if [[ -z "$SOFTWARE_UPDATE_LIST" ]]; then - # Check cache first - if is_cache_valid "$cache_file"; then - SOFTWARE_UPDATE_LIST=$(cat "$cache_file" 2> /dev/null || echo "") - else - # Show spinner while checking (only on first call) - local show_spinner=false - if [[ -t 1 && -z "${SOFTWAREUPDATE_SPINNER_SHOWN:-}" ]]; then - start_inline_spinner "Checking system updates (querying Apple servers)..." - show_spinner=true - export SOFTWAREUPDATE_SPINNER_SHOWN="true" - fi + # Optimized: Use defaults to check if updates are pending (much faster) + local pending_updates + pending_updates=$(defaults read /Library/Preferences/com.apple.SoftwareUpdate LastRecommendedUpdatesAvailable 2> /dev/null || echo "0") - SOFTWARE_UPDATE_LIST=$(softwareupdate -l 2> /dev/null || echo "") - # Save to cache - echo "$SOFTWARE_UPDATE_LIST" > "$cache_file" 2> /dev/null || true - - # Stop spinner - if [[ "$show_spinner" == "true" ]]; then - stop_inline_spinner - fi - fi + if [[ "$pending_updates" -gt 0 ]]; then + echo "Updates Available" + else + echo "" fi - echo "$SOFTWARE_UPDATE_LIST" } check_appstore_updates() { - local spinner_started=false - if [[ -t 1 ]]; then - printf " Checking App Store updates...\r" - start_inline_spinner "Checking App Store updates (querying Apple servers)..." - spinner_started=true - export SOFTWAREUPDATE_SPINNER_SHOWN="external" - else - echo "Checking App Store updates..." - fi - - local update_list="" - update_list=$(get_software_updates | grep -v "Software Update Tool" | grep "^\*" | grep -vi "macOS" || echo "") - - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - unset SOFTWAREUPDATE_SPINNER_SHOWN - fi - - local update_count=0 - if [[ -n "$update_list" ]]; then - update_count=$(echo "$update_list" | wc -l | tr -d ' ') - fi - - export APPSTORE_UPDATE_COUNT=$update_count - - if [[ $update_count -gt 0 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} App Store ${YELLOW}${update_count} apps${NC} need update" - echo -e " ${GRAY}updates available in final step${NC}" - else - echo -e " ${GREEN}✓${NC} App Store Up to date" - fi + # Skipped for speed optimization - consolidated into check_macos_update + # We can't easily distinguish app store vs macos updates without the slow softwareupdate -l call + export APPSTORE_UPDATE_COUNT=0 } check_macos_update() { # Check whitelist if command -v is_whitelisted > /dev/null && is_whitelisted "check_macos_updates"; then return; fi - local spinner_started=false - if [[ -t 1 ]]; then - printf " Checking macOS updates...\r" - start_inline_spinner "Checking macOS updates (querying Apple servers)..." - spinner_started=true - export SOFTWAREUPDATE_SPINNER_SHOWN="external" - else - echo "Checking macOS updates..." + + # Fast check using system preferences + local updates_available="false" + if [[ $(get_software_updates) == "Updates Available" ]]; then + updates_available="true" fi - # Check for macOS system update using cached list - local macos_update="" - macos_update=$(get_software_updates | grep -i "macOS" | head -1 || echo "") + export MACOS_UPDATE_AVAILABLE="$updates_available" - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - unset SOFTWAREUPDATE_SPINNER_SHOWN - fi - - export MACOS_UPDATE_AVAILABLE="false" - - if [[ -n "$macos_update" ]]; then - export MACOS_UPDATE_AVAILABLE="true" - local version=$(echo "$macos_update" | grep -o '[0-9]\+\.[0-9]\+\(\.[0-9]\+\)\?' | head -1) - if [[ -n "$version" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS ${YELLOW}${version} available${NC}" - else - echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS ${YELLOW}Update available${NC}" - fi + if [[ "$updates_available" == "true" ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS ${YELLOW}Update available${NC}" echo -e " ${GRAY}update available in final step${NC}" else echo -e " ${GREEN}✓${NC} macOS Up to date" diff --git a/lib/check/health_json.sh b/lib/check/health_json.sh index e8e58fe..dec4731 100644 --- a/lib/check/health_json.sh +++ b/lib/check/health_json.sh @@ -70,7 +70,7 @@ get_uptime_days() { local boot_output boot_time uptime_days boot_output=$(sysctl -n kern.boottime 2> /dev/null || echo "") - boot_time=$(echo "$boot_output" | sed -n 's/.*sec = \([0-9]*\).*/\1/p' 2> /dev/null || echo "") + boot_time=$(echo "$boot_output" | awk -F 'sec = |, usec' '{print $2}' 2> /dev/null || echo "") if [[ -n "$boot_time" && "$boot_time" =~ ^[0-9]+$ ]]; then local now=$(date +%s 2> /dev/null || echo "0") diff --git a/lib/core/base.sh b/lib/core/base.sh index 6ec235d..4555732 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -82,6 +82,7 @@ declare -a DEFAULT_WHITELIST_PATTERNS=( "$HOME/Library/Caches/org.R-project.R/R/renv/*" "$HOME/Library/Caches/JetBrains*" "$HOME/Library/Caches/com.jetbrains.toolbox*" + "$HOME/Library/Caches/com.apple.finder" "$FINDER_METADATA_SENTINEL" ) @@ -245,27 +246,53 @@ bytes_to_human_kb() { # Get brand-friendly name for an application # Args: $1 - application name -# Returns: branded name if mapping exists, original name otherwise +# Returns: localized name based on system language preference get_brand_name() { local name="$1" - 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" ;; - esac + # Detect if system primary language is Chinese + local is_chinese=false + local sys_lang + sys_lang=$(defaults read -g AppleLanguages 2> /dev/null | grep -o 'zh-Hans\|zh-Hant\|zh' | head -1 || echo "") + [[ -n "$sys_lang" ]] && is_chinese=true + + # Return localized names based on system language + if [[ "$is_chinese" == true ]]; then + # Chinese system - prefer Chinese names + case "$name" in + "qiyimac" | "iQiyi") echo "爱奇艺" ;; + "wechat" | "WeChat") echo "微信" ;; + "QQ") echo "QQ" ;; + "VooV Meeting") echo "腾讯会议" ;; + "dingtalk" | "DingTalk") echo "钉钉" ;; + "NeteaseMusic" | "NetEase Music") echo "网易云音乐" ;; + "BaiduNetdisk" | "Baidu NetDisk") echo "百度网盘" ;; + "alipay" | "Alipay") echo "支付宝" ;; + "taobao" | "Taobao") echo "淘宝" ;; + "futunn" | "Futu NiuNiu") echo "富途牛牛" ;; + "tencent lemon" | "Tencent Lemon Cleaner" | "Tencent Lemon") echo "腾讯柠檬清理" ;; + *) echo "$name" ;; + esac + else + # Non-Chinese system - use English names + case "$name" in + "qiyimac" | "爱奇艺") echo "iQiyi" ;; + "wechat" | "微信") echo "WeChat" ;; + "QQ") echo "QQ" ;; + "腾讯会议") echo "VooV Meeting" ;; + "dingtalk" | "钉钉") echo "DingTalk" ;; + "网易云音乐") echo "NetEase Music" ;; + "百度网盘") echo "Baidu NetDisk" ;; + "alipay" | "支付宝") echo "Alipay" ;; + "taobao" | "淘宝") echo "Taobao" ;; + "富途牛牛") echo "Futu NiuNiu" ;; + "腾讯柠檬清理" | "Tencent Lemon Cleaner") echo "Tencent Lemon" ;; + "keynote" | "Keynote") echo "Keynote" ;; + "pages" | "Pages") echo "Pages" ;; + "numbers" | "Numbers") echo "Numbers" ;; + *) echo "$name" ;; + esac + fi } # ============================================================================ diff --git a/lib/core/sudo.sh b/lib/core/sudo.sh index da443db..d44b719 100644 --- a/lib/core/sudo.sh +++ b/lib/core/sudo.sh @@ -42,6 +42,11 @@ _request_password() { # Extra safety: ensure sudo cache is cleared before password input sudo -k 2> /dev/null + # Save original terminal settings and ensure they're restored on exit + local stty_orig + stty_orig=$(stty -g < "$tty_path" 2> /dev/null || echo "") + trap '[[ -n "${stty_orig:-}" ]] && stty "${stty_orig:-}" < "$tty_path" 2> /dev/null || true' RETURN + while ((attempts < 3)); do local password="" @@ -52,7 +57,13 @@ _request_password() { fi printf "${PURPLE}${ICON_ARROW}${NC} Password: " > "$tty_path" - IFS= read -r -s password < "$tty_path" || password="" + + # Disable terminal echo to hide password input + stty -echo -icanon min 1 time 0 < "$tty_path" 2> /dev/null || true + IFS= read -r password < "$tty_path" || password="" + # Restore terminal echo immediately + stty echo icanon < "$tty_path" 2> /dev/null || true + printf "\n" > "$tty_path" if [[ -z "$password" ]]; then diff --git a/lib/core/ui.sh b/lib/core/ui.sh index 562e0c8..66ecd68 100755 --- a/lib/core/ui.sh +++ b/lib/core/ui.sh @@ -17,6 +17,129 @@ clear_screen() { printf '\033[2J\033[H'; } hide_cursor() { [[ -t 1 ]] && printf '\033[?25l' >&2 || true; } show_cursor() { [[ -t 1 ]] && printf '\033[?25h' >&2 || true; } +# Calculate display width of a string (CJK characters count as 2) +# Args: $1 - string to measure +# Returns: display width +# Note: Works correctly even when LC_ALL=C is set +get_display_width() { + local str="$1" + + # Optimized pure bash implementation without forks + local width + + # Save current locale + local old_lc="${LC_ALL:-}" + + # Get Char Count (UTF-8) + # We must export ensuring it applies to the expansion (though just assignment often works in newer bash, export is safer for all subshells/cmds) + export LC_ALL=en_US.UTF-8 + local char_count=${#str} + + # Get Byte Count (C) + export LC_ALL=C + local byte_count=${#str} + + # Restore Locale immediately + if [[ -n "$old_lc" ]]; then + export LC_ALL="$old_lc" + else + unset LC_ALL + fi + + if [[ $byte_count -eq $char_count ]]; then + echo "$char_count" + return + fi + + # CJK Heuristic: + # Most CJK chars are 3 bytes in UTF-8 and width 2. + # ASCII chars are 1 byte and width 1. + # Width ~= CharCount + (ByteCount - CharCount) / 2 + # "中" (1 char, 3 bytes) -> 1 + (2)/2 = 2. + # "A" (1 char, 1 byte) -> 1 + 0 = 1. + # This is an approximation but very fast and sufficient for App names. + # Integer arithmetic in bash automatically handles floor. + local extra_bytes=$((byte_count - char_count)) + local padding=$((extra_bytes / 2)) + width=$((char_count + padding)) + + echo "$width" +} + +# Truncate string by display width (handles CJK correctly) +# Args: $1 - string, $2 - max display width +truncate_by_display_width() { + local str="$1" + local max_width="$2" + local current_width + current_width=$(get_display_width "$str") + + if [[ $current_width -le $max_width ]]; then + echo "$str" + return + fi + + # Fallback: Use pure bash character iteration + # Since we need to know the width of *each* character to truncate at the right spot, + # we cannot just use the total width formula on the whole string. + # However, iterating char-by-char and calling the optimized get_display_width function + # is now much faster because it doesn't fork 'wc'. + + # CRITICAL: Switch to UTF-8 for correct character iteration + local old_lc="${LC_ALL:-}" + export LC_ALL=en_US.UTF-8 + + local truncated="" + local width=0 + local i=0 + local char char_width + local strlen=${#str} # Re-calculate in UTF-8 + + # Optimization: If total width <= max_width, return original string (checked above) + + while [[ $i -lt $strlen ]]; do + char="${str:$i:1}" + + # Inlined width calculation for minimal overhead to avoid recursion overhead + # We are already in UTF-8, so ${#char} is char length (1). + # We need byte length for the heuristic. + # But switching locale inside loop is disastrous for perf. + # Logic: If char is ASCII (1 byte), width 1. + # If char is wide (3 bytes), width 2. + # How to detect byte size without switching locale? + # printf %s "$char" | wc -c ? Slow. + # Check against ASCII range? + # Fast ASCII check: if [[ "$char" < $'\x7f' ]]; then ... + + if [[ "$char" =~ [[:ascii:]] ]]; then + char_width=1 + else + # Assume wide for non-ascii in this context (simplified) + # Or use LC_ALL=C inside? No. + # Most non-ASCII in filenames are either CJK (width 2) or heavy symbols. + # Let's assume 2 for simplicity in this fast loop as we know we are usually dealing with CJK. + char_width=2 + fi + + if ((width + char_width + 3 > max_width)); then + break + fi + + truncated+="$char" + ((width += char_width)) + ((i++)) + done + + # Restore locale + if [[ -n "$old_lc" ]]; then + export LC_ALL="$old_lc" + else + unset LC_ALL + fi + + echo "${truncated}..." +} + # Keyboard input - read single keypress read_key() { local key rest read_status diff --git a/lib/manage/update.sh b/lib/manage/update.sh index 8f6d8fa..8337cfd 100644 --- a/lib/manage/update.sh +++ b/lib/manage/update.sh @@ -76,174 +76,51 @@ ask_for_updates() { echo -e "$item" done echo "" - echo -ne "${YELLOW}Update all now?${NC} ${GRAY}Enter confirm / ESC cancel${NC}: " - local key - if ! key=$(read_key); then - echo "skip" - echo "" - return 1 + # If Mole has updates, offer to update it + if [[ "${MOLE_UPDATE_AVAILABLE:-}" == "true" ]]; then + echo -ne "${YELLOW}Update Mole now?${NC} ${GRAY}Enter confirm / ESC cancel${NC}: " + + local key + if ! key=$(read_key); then + echo "skip" + echo "" + return 1 + fi + + if [[ "$key" == "ENTER" ]]; then + echo "yes" + echo "" + return 0 + else + echo "skip" + echo "" + return 1 + fi fi - if [[ "$key" == "ENTER" ]]; then - echo "yes" - echo "" - return 0 - else - echo "skip" - echo "" - return 1 + # For other updates, just show instructions + # (Mole update check above handles the return 0 case, so we only get here if no Mole update) + if [[ "${BREW_OUTDATED_COUNT:-0}" -gt 0 ]]; then + echo -e "${YELLOW}Tip:${NC} Run ${GREEN}brew upgrade${NC} to update Homebrew packages" fi + if [[ "${APPSTORE_UPDATE_COUNT:-0}" -gt 0 ]]; then + echo -e "${YELLOW}Tip:${NC} Open ${BLUE}App Store${NC} to update apps" + fi + if [[ "${MACOS_UPDATE_AVAILABLE:-}" == "true" ]]; then + echo -e "${YELLOW}Tip:${NC} Open ${BLUE}System Settings${NC} to update macOS" + fi + echo "" + return 1 } # Perform all pending updates # Returns: 0 if all succeeded, 1 if some failed perform_updates() { + # Only handle Mole updates here + # Other updates are now informational-only in ask_for_updates + local updated_count=0 - local total_count=0 - local brew_formula="${BREW_FORMULA_OUTDATED_COUNT:-0}" - local brew_cask="${BREW_CASK_OUTDATED_COUNT:-0}" - - # Get update labels - local -a appstore_labels=() - if [[ -n "${APPSTORE_UPDATE_COUNT:-}" && "${APPSTORE_UPDATE_COUNT:-0}" -gt 0 ]]; then - while IFS= read -r label; do - [[ -n "$label" ]] && appstore_labels+=("$label") - done < <(get_appstore_update_labels || true) - fi - - local -a macos_labels=() - if [[ -n "${MACOS_UPDATE_AVAILABLE:-}" && "${MACOS_UPDATE_AVAILABLE}" == "true" ]]; then - while IFS= read -r label; do - [[ -n "$label" ]] && macos_labels+=("$label") - done < <(get_macos_update_labels || true) - fi - - # Check fallback needed - local appstore_needs_fallback=false - local macos_needs_fallback=false - if [[ -n "${APPSTORE_UPDATE_COUNT:-}" && "${APPSTORE_UPDATE_COUNT:-0}" -gt 0 && ${#appstore_labels[@]} -eq 0 ]]; then - appstore_needs_fallback=true - fi - if [[ -n "${MACOS_UPDATE_AVAILABLE:-}" && "${MACOS_UPDATE_AVAILABLE}" == "true" && ${#macos_labels[@]} -eq 0 ]]; then - macos_needs_fallback=true - fi - - # Count total updates - ((brew_formula > 0)) && ((total_count++)) - ((brew_cask > 0)) && ((total_count++)) - [[ -n "${APPSTORE_UPDATE_COUNT:-}" && "${APPSTORE_UPDATE_COUNT:-0}" -gt 0 ]] && ((total_count++)) - [[ -n "${MACOS_UPDATE_AVAILABLE:-}" && "${MACOS_UPDATE_AVAILABLE}" == "true" ]] && ((total_count++)) - [[ -n "${MOLE_UPDATE_AVAILABLE:-}" && "${MOLE_UPDATE_AVAILABLE}" == "true" ]] && ((total_count++)) - - # Update Homebrew formulae - if ((brew_formula > 0)); then - if ! brew_has_outdated "formula"; then - echo -e "${GRAY}-${NC} Homebrew formulae already up to date" - ((total_count--)) - echo "" - else - echo -e "${BLUE}Updating Homebrew formulae...${NC}" - local spinner_started=false - if [[ -t 1 ]]; then - start_inline_spinner "Running brew upgrade" - spinner_started=true - fi - - local brew_output="" - local brew_status=0 - if ! brew_output=$(brew upgrade --formula 2>&1); then - brew_status=$? - fi - - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - - local filtered_output - filtered_output=$(echo "$brew_output" | grep -Ev "^(==>|Warning:)" || true) - [[ -n "$filtered_output" ]] && echo "$filtered_output" - - if [[ ${brew_status:-0} -eq 0 ]]; then - echo -e "${GREEN}✓${NC} Homebrew formulae updated" - reset_brew_cache - ((updated_count++)) - else - echo -e "${RED}✗${NC} Homebrew formula update failed" - fi - echo "" - fi - fi - - # Update Homebrew casks - if ((brew_cask > 0)); then - if ! brew_has_outdated "cask"; then - echo -e "${GRAY}-${NC} Homebrew casks already up to date" - ((total_count--)) - echo "" - else - echo -e "${BLUE}Updating Homebrew casks...${NC}" - local spinner_started=false - if [[ -t 1 ]]; then - start_inline_spinner "Running brew upgrade --cask" - spinner_started=true - fi - - local brew_output="" - local brew_status=0 - if ! brew_output=$(brew upgrade --cask 2>&1); then - brew_status=$? - fi - - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - - local filtered_output - filtered_output=$(echo "$brew_output" | grep -Ev "^(==>|Warning:)" || true) - [[ -n "$filtered_output" ]] && echo "$filtered_output" - - if [[ ${brew_status:-0} -eq 0 ]]; then - echo -e "${GREEN}✓${NC} Homebrew casks updated" - reset_brew_cache - ((updated_count++)) - else - echo -e "${RED}✗${NC} Homebrew cask update failed" - fi - echo "" - fi - fi - - # Update App Store apps - local macos_handled_via_appstore=false - if [[ -n "${APPSTORE_UPDATE_COUNT:-}" && "${APPSTORE_UPDATE_COUNT:-0}" -gt 0 ]]; then - # Check sudo access - if ! has_sudo_session; then - if ! ensure_sudo_session "Software updates require admin access"; then - echo -e "${YELLOW}☻${NC} App Store updates available — update via System Settings" - echo "" - ((total_count--)) - if [[ -n "${MACOS_UPDATE_AVAILABLE:-}" && "${MACOS_UPDATE_AVAILABLE}" == "true" ]]; then - ((total_count--)) - fi - else - _perform_appstore_update - fi - else - _perform_appstore_update - fi - fi - - # Update macOS - if [[ -n "${MACOS_UPDATE_AVAILABLE:-}" && "${MACOS_UPDATE_AVAILABLE}" == "true" && "$macos_handled_via_appstore" != "true" ]]; then - if ! has_sudo_session; then - echo -e "${YELLOW}☻${NC} macOS updates available — update via System Settings" - echo "" - ((total_count--)) - else - _perform_macos_update - fi - fi # Update Mole if [[ -n "${MOLE_UPDATE_AVAILABLE:-}" && "${MOLE_UPDATE_AVAILABLE}" == "true" ]]; then @@ -253,10 +130,17 @@ perform_updates() { [[ ! -f "$mole_bin" ]] && mole_bin=$(command -v mole 2> /dev/null || echo "") if [[ -x "$mole_bin" ]]; then + # We use exec here or just run it? + # If we run 'mole update', it replaces the script. + # Since this function is part of a sourced script, replacing the file on disk is risky while running. + # However, 'mole update' script usually handles this by downloading to a temp file and moving it. + # But the shell might not like the file changing under it. + # The original code ran it this way, so we assume it's safe enough or handled by mole update implementation. + if "$mole_bin" update 2>&1 | grep -qE "(Updated|latest version)"; then echo -e "${GREEN}✓${NC} Mole updated" reset_mole_cache - ((updated_count++)) + updated_count=1 else echo -e "${RED}✗${NC} Mole update failed" fi @@ -266,86 +150,9 @@ perform_updates() { echo "" fi - # Summary - if [[ $total_count -eq 0 ]]; then - echo -e "${GRAY}No updates to perform${NC}" - return 0 - elif [[ $updated_count -eq $total_count ]]; then - echo -e "${GREEN}All updates completed (${updated_count}/${total_count})${NC}" - return 0 - elif [[ $updated_count -gt 0 ]]; then - echo -e "${YELLOW}Partial updates completed (${updated_count}/${total_count})${NC}" + if [[ $updated_count -gt 0 ]]; then return 0 else - echo -e "${RED}No updates were completed${NC}" - return 0 + return 1 fi } - -# Internal: Perform App Store update -_perform_appstore_update() { - echo -e "${BLUE}Updating App Store apps...${NC}" - local appstore_log - appstore_log=$(mktemp "${TMPDIR:-/tmp}/mole-appstore.XXXXXX" 2> /dev/null || echo "/tmp/mole-appstore.log") - - if [[ "$appstore_needs_fallback" == "true" ]]; then - echo -e " ${GRAY}Installing all available updates${NC}" - echo -e " ${GRAY}Contacting Apple servers (this may take a few minutes)...${NC}" - if sudo softwareupdate -i -a 2>&1 | tee "$appstore_log" | grep -v "^$"; then - echo -e "${GREEN}✓${NC} Software updates completed" - ((updated_count++)) - if [[ -n "${MACOS_UPDATE_AVAILABLE:-}" && "${MACOS_UPDATE_AVAILABLE}" == "true" ]]; then - macos_handled_via_appstore=true - ((updated_count++)) - fi - else - echo -e "${RED}✗${NC} Software update failed" - fi - else - echo -e " ${GRAY}Contacting Apple servers (this may take a few minutes)...${NC}" - if sudo softwareupdate -i "${appstore_labels[@]}" 2>&1 | tee "$appstore_log" | grep -v "^$"; then - echo -e "${GREEN}✓${NC} App Store apps updated" - ((updated_count++)) - else - echo -e "${RED}✗${NC} App Store update failed" - fi - fi - rm -f "$appstore_log" 2> /dev/null || true - reset_softwareupdate_cache - echo "" -} - -# Internal: Perform macOS update -_perform_macos_update() { - echo -e "${BLUE}Updating macOS...${NC}" - echo -e "${YELLOW}Note:${NC} System update may require restart" - - local macos_log - macos_log=$(mktemp "${TMPDIR:-/tmp}/mole-macos.XXXXXX" 2> /dev/null || echo "/tmp/mole-macos.log") - - if [[ "$macos_needs_fallback" == "true" ]]; then - echo -e " ${GRAY}Contacting Apple servers (this may take a few minutes)...${NC}" - if sudo softwareupdate -i -r 2>&1 | tee "$macos_log" | grep -v "^$"; then - echo -e "${GREEN}✓${NC} macOS updated" - ((updated_count++)) - else - echo -e "${RED}✗${NC} macOS update failed" - fi - else - echo -e " ${GRAY}Contacting Apple servers (this may take a few minutes)...${NC}" - if sudo softwareupdate -i "${macos_labels[@]}" 2>&1 | tee "$macos_log" | grep -v "^$"; then - echo -e "${GREEN}✓${NC} macOS updated" - ((updated_count++)) - else - echo -e "${RED}✗${NC} macOS update failed" - fi - fi - - if grep -qi "restart" "$macos_log" 2> /dev/null; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Restart required to complete update" - fi - - rm -f "$macos_log" 2> /dev/null || true - reset_softwareupdate_cache - echo "" -} diff --git a/lib/optimize/maintenance.sh b/lib/optimize/maintenance.sh index d21e5a7..2baaee6 100644 --- a/lib/optimize/maintenance.sh +++ b/lib/optimize/maintenance.sh @@ -74,6 +74,9 @@ fix_broken_login_items() { local launch_agents_dir="$HOME/Library/LaunchAgents" [[ -d "$launch_agents_dir" ]] || return 0 + # Check whitelist + if command -v is_whitelisted > /dev/null && is_whitelisted "check_login_items"; then return 0; fi + local broken_count=0 while IFS= read -r plist_file; do @@ -97,6 +100,9 @@ fix_broken_login_items() { program=$(plutil -extract ProgramArguments.0 raw "$plist_file" 2> /dev/null || echo "") fi + # Expand tilde in path if present + program="${program/#\~/$HOME}" + # Skip if no program found or program exists [[ -z "$program" ]] && continue [[ -e "$program" ]] && continue diff --git a/lib/ui/app_selector.sh b/lib/ui/app_selector.sh index 7454b6b..49f8638 100755 --- a/lib/ui/app_selector.sh +++ b/lib/ui/app_selector.sh @@ -3,6 +3,8 @@ set -euo pipefail +# Note: get_display_width() is now defined in lib/core/ui.sh + # Format app info for display format_app_display() { local display_name="$1" size="$2" last_used="$3" @@ -16,22 +18,31 @@ format_app_display() { [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]] && size_str="$size" # Calculate available width for app name based on terminal width - local terminal_width=$(tput cols 2> /dev/null || echo 80) + # use passed width or calculate it (but calculation is slow in loops) + local terminal_width="${4:-$(tput cols 2> /dev/null || echo 80)}" local fixed_width=28 local available_width=$((terminal_width - fixed_width)) - # Set reasonable bounds for name width: 24-35 chars + # Set reasonable bounds for name width: 24-35 display width [[ $available_width -lt 24 ]] && available_width=24 [[ $available_width -gt 35 ]] && available_width=35 - # Truncate long names if needed - local truncated_name="$display_name" - if [[ ${#display_name} -gt $available_width ]]; then - truncated_name="${display_name:0:$((available_width - 3))}..." - fi + # Truncate long names if needed (based on display width, not char count) + local truncated_name + truncated_name=$(truncate_by_display_width "$display_name" "$available_width") - # Use dynamic column width for better readability - printf "%-*s %9s | %s" "$available_width" "$truncated_name" "$size_str" "$compact_last_used" + # Get actual display width after truncation + local current_display_width + current_display_width=$(get_display_width "$truncated_name") + + # Calculate padding needed + # Formula: char_count + (available_width - display_width) = padding to add + local char_count=${#truncated_name} + local padding_needed=$((available_width - current_display_width)) + local printf_width=$((char_count + padding_needed)) + + # Use dynamic column width with corrected padding + printf "%-*s %9s | %s" "$printf_width" "$truncated_name" "$size_str" "$compact_last_used" } # Global variable to store selection result (bash 3.2 compatible) @@ -46,6 +57,15 @@ select_apps_for_uninstall() { fi # Build menu options + # Show loading for large lists (formatting can be slow due to width calculations) + local app_count=${#apps_data[@]} + local terminal_width=$(tput cols 2> /dev/null || echo 80) + if [[ $app_count -gt 100 ]]; then + if [[ -t 2 ]]; then + printf "\rPreparing %d applications... " "$app_count" >&2 + fi + fi + local -a menu_options=() # Prepare metadata (comma-separated) for sorting/filtering inside the menu local epochs_csv="" @@ -54,7 +74,7 @@ select_apps_for_uninstall() { for app_data in "${apps_data[@]}"; do # Keep extended field 7 (size_kb) if present IFS='|' read -r epoch _ display_name _ size last_used size_kb <<< "$app_data" - menu_options+=("$(format_app_display "$display_name" "$size" "$last_used")") + menu_options+=("$(format_app_display "$display_name" "$size" "$last_used" "$terminal_width")") # Build csv lists (avoid trailing commas) if [[ $idx -eq 0 ]]; then epochs_csv="${epoch:-0}" @@ -66,6 +86,13 @@ select_apps_for_uninstall() { ((idx++)) done + # Clear loading message + if [[ $app_count -gt 100 ]]; then + if [[ -t 2 ]]; then + printf "\r\033[K" >&2 + fi + fi + # Expose metadata for the paginated menu (optional inputs) # - MOLE_MENU_META_EPOCHS: numeric last_used_epoch per item # - MOLE_MENU_META_SIZEKB: numeric size in KB per item diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index f477ef8..ffc6531 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -130,9 +130,10 @@ batch_uninstall_applications() { running_apps+=("$app_name") fi - # Check if app requires sudo to delete + # Check if app requires sudo to delete (either app bundle or system files) + local needs_sudo=false if [[ ! -w "$(dirname "$app_path")" ]] || [[ "$(get_file_owner "$app_path")" == "root" ]]; then - sudo_apps+=("$app_name") + needs_sudo=true fi # Calculate size for summary (including system files) @@ -150,6 +151,10 @@ batch_uninstall_applications() { # Check if system files require sudo # shellcheck disable=SC2128 if [[ -n "$system_files" ]]; then + needs_sudo=true + fi + + if [[ "$needs_sudo" == "true" ]]; then sudo_apps+=("$app_name") fi @@ -401,7 +406,12 @@ batch_uninstall_applications() { summary_details+=("No applications were uninstalled.") fi - print_summary_block "$summary_status" "Uninstall complete" "${summary_details[@]}" + local title="Uninstall complete" + if [[ "$summary_status" == "warn" ]]; then + title="Uninstall incomplete" + fi + + print_summary_block "$title" "${summary_details[@]}" printf '\n' # Clean up Dock entries for uninstalled apps diff --git a/mole b/mole index 5225ad2..09c6ff7 100755 --- a/mole +++ b/mole @@ -22,8 +22,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/core/common.sh" # Version info -VERSION="1.13.0" -MOLE_TAGLINE="can dig deep to clean your Mac." +VERSION="1.13.5" +MOLE_TAGLINE="Deep clean and optimize your Mac." # Check if Touch ID is already configured is_touchid_configured() { @@ -181,7 +181,44 @@ EOF } show_version() { - printf '\nMole version %s\n\n' "$VERSION" + local os_ver + if command -v sw_vers > /dev/null; then + os_ver=$(sw_vers -productVersion) + else + os_ver="Unknown" + fi + + local arch + arch=$(uname -m) + + local kernel + kernel=$(uname -r) + + local sip_status + if command -v csrutil > /dev/null; then + sip_status=$(csrutil status 2> /dev/null | grep -o "enabled\|disabled" || echo "Unknown") + # Capitalize first letter + sip_status="$(tr '[:lower:]' '[:upper:]' <<< ${sip_status:0:1})${sip_status:1}" + else + sip_status="Unknown" + fi + + local disk_free + disk_free=$(df -h / 2> /dev/null | awk 'NR==2 {print $4}' || echo "Unknown") + + local install_method="Manual" + if is_homebrew_install; then + install_method="Homebrew" + fi + + printf '\nMole version %s\n' "$VERSION" + printf 'macOS: %s\n' "$os_ver" + printf 'Architecture: %s\n' "$arch" + printf 'Kernel: %s\n' "$kernel" + printf 'SIP: %s\n' "$sip_status" + printf 'Disk Free: %s\n' "$disk_free" + printf 'Install: %s\n' "$install_method" + printf 'Shell: %s\n\n' "${SHELL:-Unknown}" } show_help() {