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 @@
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() {