1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-05 13:48:48 +00:00

Merge branch 'main' into dev

This commit is contained in:
Tw93
2025-12-17 14:04:28 +08:00
18 changed files with 546 additions and 554 deletions

View File

@@ -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

View File

@@ -1,6 +1,6 @@
<div align="center">
<h1>Mole</h1>
<p><em>Dig deep like a mole to optimize your Mac.</em></p>
<p><em>Deep clean and optimize your Mac.</em></p>
</div>
<p align="center">
@@ -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
<a href="https://miaoyan.app/cats.html?name=Mole"><img src="https://miaoyan.app/assets/sponsors.svg" width="1000px" /></a>
- 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 <a href="https://miaoyan.app/cats.html?name=Mole" target="_blank">this link</a> 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 <a href="https://miaoyan.app/cats.html?name=Mole" target="_blank">this link</a> to keep our mascots purring.
## License

Binary file not shown.

View File

@@ -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++))
;;

View File

@@ -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

Binary file not shown.

View File

@@ -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
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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")

View File

@@ -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
}
# ============================================================================

View File

@@ -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

View File

@@ -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

View File

@@ -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 ""
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

43
mole
View File

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