1
0
mirror of https://github.com/tw93/Mole.git synced 2026-03-22 19:40:07 +00:00

Merge branch 'main' into dev

This commit is contained in:
tw93
2026-03-03 15:42:13 +08:00
59 changed files with 2303 additions and 1299 deletions

View File

@@ -33,7 +33,7 @@ clean_ds_store_tree() {
local size
size=$(get_file_size "$ds_file")
total_bytes=$((total_bytes + size))
((file_count++))
file_count=$((file_count + 1))
if [[ "$DRY_RUN" != "true" ]]; then
rm -f "$ds_file" 2> /dev/null || true
fi
@@ -53,9 +53,9 @@ clean_ds_store_tree() {
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$file_count files, $size_human${NC}"
fi
local size_kb=$(((total_bytes + 1023) / 1024))
((files_cleaned += file_count))
((total_size_cleaned += size_kb))
((total_items++))
files_cleaned=$((files_cleaned + file_count))
total_size_cleaned=$((total_size_cleaned + size_kb))
total_items=$((total_items + 1))
note_activity
fi
}
@@ -113,12 +113,12 @@ scan_installed_apps() {
local bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$plist_path" 2> /dev/null || echo "")
if [[ -n "$bundle_id" ]]; then
echo "$bundle_id"
((count++))
count=$((count + 1))
fi
done
) > "$scan_tmp_dir/apps_${dir_idx}.txt" &
pids+=($!)
((dir_idx++))
dir_idx=$((dir_idx + 1))
done
# Collect running apps and LaunchAgents to avoid false orphan cleanup.
(
@@ -300,7 +300,7 @@ clean_orphaned_app_data() {
fi
for match in "${matches[@]}"; do
[[ -e "$match" ]] || continue
((iteration_count++))
iteration_count=$((iteration_count + 1))
if [[ $iteration_count -gt $MOLE_MAX_ORPHAN_ITERATIONS ]]; then
break
fi
@@ -314,8 +314,8 @@ clean_orphaned_app_data() {
continue
fi
if safe_clean "$match" "Orphaned $label: $bundle_id"; then
((orphaned_count++))
((total_orphaned_kb += size_kb))
orphaned_count=$((orphaned_count + 1))
total_orphaned_kb=$((total_orphaned_kb + size_kb))
fi
fi
done
@@ -326,7 +326,7 @@ clean_orphaned_app_data() {
stop_section_spinner
if [[ $orphaned_count -gt 0 ]]; then
local orphaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}')
echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count items, about ${orphaned_mb}MB"
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count items, about ${orphaned_mb}MB"
note_activity
fi
rm -f "$installed_bundles"
@@ -430,8 +430,8 @@ clean_orphaned_system_services() {
orphaned_files+=("$plist")
local size_kb
size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0")
((total_orphaned_kb += size_kb))
((orphaned_count++))
total_orphaned_kb=$((total_orphaned_kb + size_kb))
orphaned_count=$((orphaned_count + 1))
break
fi
done
@@ -461,8 +461,8 @@ clean_orphaned_system_services() {
orphaned_files+=("$plist")
local size_kb
size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0")
((total_orphaned_kb += size_kb))
((orphaned_count++))
total_orphaned_kb=$((total_orphaned_kb + size_kb))
orphaned_count=$((orphaned_count + 1))
break
fi
done
@@ -491,8 +491,8 @@ clean_orphaned_system_services() {
orphaned_files+=("$helper")
local size_kb
size_kb=$(sudo du -skP "$helper" 2> /dev/null | awk '{print $1}' || echo "0")
((total_orphaned_kb += size_kb))
((orphaned_count++))
total_orphaned_kb=$((total_orphaned_kb + size_kb))
orphaned_count=$((orphaned_count + 1))
break
fi
done
@@ -673,7 +673,7 @@ clean_orphaned_launch_agents() {
if is_launch_item_orphaned "$plist"; then
local size_kb=$(get_path_size_kb "$plist")
orphaned_items+=("$bundle_id|$plist")
((total_orphaned_kb += size_kb))
total_orphaned_kb=$((total_orphaned_kb + size_kb))
fi
done < <(find "$launch_agents_dir" -maxdepth 1 -name "*.plist" -print0 2> /dev/null)
@@ -696,7 +696,7 @@ clean_orphaned_launch_agents() {
IFS='|' read -r bundle_id plist_path <<< "$item"
if [[ "$is_dry_run" == "true" ]]; then
((dry_run_count++))
dry_run_count=$((dry_run_count + 1))
log_operation "clean" "DRY_RUN" "$plist_path" "orphaned launch agent"
continue
fi
@@ -706,7 +706,7 @@ clean_orphaned_launch_agents() {
# Remove the plist file
if safe_remove "$plist_path" false; then
((removed_count++))
removed_count=$((removed_count + 1))
log_operation "clean" "REMOVED" "$plist_path" "orphaned launch agent"
else
log_operation "clean" "FAILED" "$plist_path" "permission denied"

View File

@@ -5,7 +5,12 @@
clean_homebrew() {
command -v brew > /dev/null 2>&1 || return 0
if [[ "${DRY_RUN:-false}" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Homebrew · would cleanup and autoremove"
# Check if Homebrew cache is whitelisted
if is_path_whitelisted "$HOME/Library/Caches/Homebrew"; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew · skipped whitelist"
else
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Homebrew · would cleanup and autoremove"
fi
return 0
fi
# Skip if cleaned recently to avoid repeated heavy operations.

View File

@@ -146,6 +146,8 @@ clean_project_caches() {
done
if [[ "$spinner_active" == "true" ]]; then
stop_inline_spinner 2> /dev/null || true
# Extra clear to prevent spinner character remnants in terminal
[[ -t 1 ]] && printf "\r\033[2K" >&2 || true
fi
[[ "$has_dev_projects" == "false" ]] && return 0
fi
@@ -208,7 +210,7 @@ clean_project_caches() {
break
fi
sleep 0.1
((grace_period++))
grace_period=$((grace_period + 1))
done
if kill -0 "$pid" 2> /dev/null; then
kill -KILL "$pid" 2> /dev/null || true

View File

@@ -7,7 +7,17 @@ clean_tool_cache() {
local description="$1"
shift
if [[ "$DRY_RUN" != "true" ]]; then
local command_succeeded=false
if [[ -t 1 ]]; then
start_section_spinner "Cleaning $description..."
fi
if "$@" > /dev/null 2>&1; then
command_succeeded=true
fi
if [[ -t 1 ]]; then
stop_section_spinner
fi
if [[ "$command_succeeded" == "true" ]]; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $description"
fi
else
@@ -85,7 +95,8 @@ clean_dev_npm() {
}
# Python/pip ecosystem caches.
clean_dev_python() {
if command -v pip3 > /dev/null 2>&1; then
# Check pip3 is functional (not just macOS stub that triggers CLT install dialog)
if command -v pip3 > /dev/null 2>&1 && pip3 --version > /dev/null 2>&1; then
clean_tool_cache "pip cache" bash -c 'pip3 cache purge > /dev/null 2>&1 || true'
note_activity
fi
@@ -249,11 +260,11 @@ clean_xcode_documentation_cache() {
local entry
for entry in "${sorted_entries[@]}"; do
if [[ $idx -eq 0 ]]; then
((idx++))
idx=$((idx + 1))
continue
fi
stale_entries+=("$entry")
((idx++))
idx=$((idx + 1))
done
if [[ ${#stale_entries[@]} -eq 0 ]]; then
@@ -380,7 +391,7 @@ clean_xcode_simulator_runtime_volumes() {
local unused_count=0
for candidate in "${sorted_candidates[@]}"; do
local status="UNUSED"
if _sim_runtime_is_path_in_use "$candidate" "${mount_points[@]}"; then
if [[ ${#mount_points[@]} -gt 0 ]] && _sim_runtime_is_path_in_use "$candidate" "${mount_points[@]}"; then
status="IN_USE"
in_use_count=$((in_use_count + 1))
else
@@ -791,12 +802,12 @@ clean_dev_jetbrains_toolbox() {
local dir_path
for dir_path in "${sorted_dirs[@]}"; do
if [[ $idx -lt $keep_previous ]]; then
((idx++))
idx=$((idx + 1))
continue
fi
safe_clean "$dir_path" "JetBrains Toolbox old IDE version"
note_activity
((idx++))
idx=$((idx + 1))
done
done < <(command find "$product_dir" -mindepth 1 -maxdepth 1 -type d -name "ch-*" -print0 2> /dev/null)
done

View File

@@ -3,9 +3,9 @@
set -euo pipefail
_MOLE_HINTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
mole_hints_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1090
source "$_MOLE_HINTS_DIR/purge_shared.sh"
source "$mole_hints_dir/purge_shared.sh"
# Quick reminder probe for project build artifacts handled by `mo purge`.
# Designed to be very fast: shallow directory checks only, no deep find scans.
@@ -58,7 +58,7 @@ hint_get_path_size_kb_with_timeout() {
record_project_artifact_hint() {
local path="$1"
((PROJECT_ARTIFACT_HINT_COUNT++))
PROJECT_ARTIFACT_HINT_COUNT=$((PROJECT_ARTIFACT_HINT_COUNT + 1))
if [[ ${#PROJECT_ARTIFACT_HINT_EXAMPLES[@]} -lt 2 ]]; then
PROJECT_ARTIFACT_HINT_EXAMPLES+=("${path/#$HOME/~}")
@@ -74,8 +74,8 @@ record_project_artifact_hint() {
local size_kb=""
if size_kb=$(hint_get_path_size_kb_with_timeout "$path" "$timeout_seconds"); then
if [[ "$size_kb" =~ ^[0-9]+$ ]]; then
((PROJECT_ARTIFACT_HINT_ESTIMATED_KB += size_kb))
((PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES++))
PROJECT_ARTIFACT_HINT_ESTIMATED_KB=$((PROJECT_ARTIFACT_HINT_ESTIMATED_KB + size_kb))
PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES=$((PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES + 1))
else
PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=true
fi
@@ -140,8 +140,8 @@ probe_project_artifact_hints() {
local root_projects_scanned=0
if is_quick_purge_project_root "$root"; then
((scanned_projects++))
((root_projects_scanned++))
scanned_projects=$((scanned_projects + 1))
root_projects_scanned=$((root_projects_scanned + 1))
if [[ $scanned_projects -gt $max_projects ]]; then
PROJECT_ARTIFACT_HINT_TRUNCATED=true
stop_scan=true
@@ -175,8 +175,8 @@ probe_project_artifact_hints() {
break
fi
((scanned_projects++))
((root_projects_scanned++))
scanned_projects=$((scanned_projects + 1))
root_projects_scanned=$((root_projects_scanned + 1))
if [[ $scanned_projects -gt $max_projects ]]; then
PROJECT_ARTIFACT_HINT_TRUNCATED=true
stop_scan=true
@@ -206,7 +206,7 @@ probe_project_artifact_hints() {
;;
esac
((nested_count++))
nested_count=$((nested_count + 1))
if [[ $nested_count -gt $max_nested_per_project ]]; then
break
fi

View File

@@ -569,16 +569,38 @@ select_purge_categories() {
fi
done
local original_stty=""
local previous_exit_trap=""
local previous_int_trap=""
local previous_term_trap=""
local terminal_restored=false
if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then
original_stty=$(stty -g 2> /dev/null || echo "")
fi
previous_exit_trap=$(trap -p EXIT || true)
previous_int_trap=$(trap -p INT || true)
previous_term_trap=$(trap -p TERM || true)
# Terminal control functions
restore_terminal() {
# Avoid trap churn when restore is called repeatedly via RETURN/EXIT paths.
if [[ "${terminal_restored:-false}" == "true" ]]; then
return
fi
terminal_restored=true
trap - EXIT INT TERM
show_cursor
if [[ -n "${original_stty:-}" ]]; then
stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true
fi
if [[ -n "$previous_exit_trap" ]]; then
eval "$previous_exit_trap"
fi
if [[ -n "$previous_int_trap" ]]; then
eval "$previous_int_trap"
fi
if [[ -n "$previous_term_trap" ]]; then
eval "$previous_term_trap"
fi
}
# shellcheck disable=SC2329
handle_interrupt() {
@@ -618,7 +640,7 @@ select_purge_categories() {
for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then
selected_size=$((selected_size + ${sizes[i]:-0}))
((selected_count++))
selected_count=$((selected_count + 1))
fi
done
@@ -633,7 +655,6 @@ select_purge_categories() {
scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}"
fi
printf "%s\n" "$clear_line"
printf "%s${PURPLE_BOLD}Select Categories to Clean${NC}%s${GRAY}, ${selected_size_human}, ${selected_count} selected${NC}\n" "$clear_line" "$scroll_indicator"
printf "%s\n" "$clear_line"
@@ -656,15 +677,42 @@ select_purge_categories() {
fi
done
# Fill empty slots to clear previous content
local items_shown=$visible_count
for ((i = items_shown; i < items_per_page; i++)); do
printf "%s\n" "$clear_line"
done
# Keep one blank line between the list and footer tips.
printf "%s\n" "$clear_line"
printf "%s${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}/J/K | Space Select | Enter Confirm | A All | I Invert | Q Quit${NC}\n" "$clear_line"
# Adaptive footer hints — mirrors menu_paginated.sh pattern
local _term_w
_term_w=$(tput cols 2> /dev/null || echo 80)
[[ "$_term_w" =~ ^[0-9]+$ ]] || _term_w=80
local _sep=" ${GRAY}|${NC} "
local _nav="${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}${NC}"
local _space="${GRAY}Space Select${NC}"
local _enter="${GRAY}Enter Confirm${NC}"
local _all="${GRAY}A All${NC}"
local _invert="${GRAY}I Invert${NC}"
local _quit="${GRAY}Q Quit${NC}"
# Strip ANSI to measure real length
_ph_len() { printf "%s" "$1" | LC_ALL=C awk '{gsub(/\033\[[0-9;]*[A-Za-z]/,""); printf "%d", length}'; }
# Level 0 (full): ↑↓ | Space Select | Enter Confirm | A All | I Invert | Q Quit
local _full="${_nav}${_sep}${_space}${_sep}${_enter}${_sep}${_all}${_sep}${_invert}${_sep}${_quit}"
if (($(_ph_len "$_full") <= _term_w)); then
printf "%s${_full}${NC}\n" "$clear_line"
else
# Level 1: ↑↓ | Enter Confirm | A All | I Invert | Q Quit
local _l1="${_nav}${_sep}${_enter}${_sep}${_all}${_sep}${_invert}${_sep}${_quit}"
if (($(_ph_len "$_l1") <= _term_w)); then
printf "%s${_l1}${NC}\n" "$clear_line"
else
# Level 2 (minimal): ↑↓ | Enter | Q Quit
printf "%s${_nav}${_sep}${_enter}${_sep}${_quit}${NC}\n" "$clear_line"
fi
fi
# Clear stale content below the footer when list height shrinks.
printf '\033[J'
}
move_cursor_up() {
if [[ $cursor_pos -gt 0 ]]; then
@@ -680,9 +728,9 @@ select_purge_categories() {
local visible_count=$((total_items - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
((cursor_pos++))
cursor_pos=$((cursor_pos + 1))
elif [[ $((top_index + visible_count)) -lt $total_items ]]; then
((top_index++))
top_index=$((top_index + 1))
fi
fi
}
@@ -767,6 +815,48 @@ select_purge_categories() {
esac
done
}
# Final confirmation before deleting selected purge artifacts.
confirm_purge_cleanup() {
local item_count="${1:-0}"
local total_size_kb="${2:-0}"
local unknown_count="${3:-0}"
[[ "$item_count" =~ ^[0-9]+$ ]] || item_count=0
[[ "$total_size_kb" =~ ^[0-9]+$ ]] || total_size_kb=0
[[ "$unknown_count" =~ ^[0-9]+$ ]] || unknown_count=0
local item_text="artifact"
[[ $item_count -ne 1 ]] && item_text="artifacts"
local size_display
size_display=$(bytes_to_human "$((total_size_kb * 1024))")
local unknown_hint=""
if [[ $unknown_count -gt 0 ]]; then
local unknown_text="unknown size"
[[ $unknown_count -gt 1 ]] && unknown_text="unknown sizes"
unknown_hint=", ${unknown_count} ${unknown_text}"
fi
echo -ne "${PURPLE}${ICON_ARROW}${NC} Remove ${item_count} ${item_text}, ${size_display}${unknown_hint} ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: "
drain_pending_input
local key=""
IFS= read -r -s -n1 key || key=""
drain_pending_input
case "$key" in
"" | $'\n' | $'\r' | y | Y)
echo ""
return 0
;;
*)
echo ""
return 1
;;
esac
}
# Main cleanup function - scans and prompts user to select artifacts to clean
clean_project_artifacts() {
local -a all_found_items=()
@@ -825,8 +915,6 @@ clean_project_artifacts() {
# Give monitor process time to exit and clear its output
if [[ -t 1 ]]; then
sleep 0.2
# Clear the scanning line but preserve the title
printf '\n\033[K'
fi
# Collect all results
@@ -1041,32 +1129,57 @@ clean_project_artifacts() {
echo "$artifact_name"
fi
}
# Format display with alignment (like app_selector)
# Format display with alignment (mirrors app_selector.sh approach)
# Args: $1=project_path $2=artifact_type $3=size_str $4=terminal_width $5=max_path_width $6=artifact_col_width
format_purge_display() {
local project_path="$1"
local artifact_type="$2"
local size_str="$3"
# Terminal width for alignment
local terminal_width=$(tput cols 2> /dev/null || echo 80)
local fixed_width=32 # Reserve for size and artifact type (9 + 3 + 20)
local available_width=$((terminal_width - fixed_width))
# Bounds: 30 chars min, but cap at 70% of terminal width to preserve aesthetics
local max_aesthetic_width=$((terminal_width * 70 / 100))
[[ $available_width -gt $max_aesthetic_width ]] && available_width=$max_aesthetic_width
[[ $available_width -lt 30 ]] && available_width=30
local terminal_width="${4:-$(tput cols 2> /dev/null || echo 80)}"
local max_path_width="${5:-}"
local artifact_col="${6:-12}"
local available_width
if [[ -n "$max_path_width" ]]; then
available_width="$max_path_width"
else
# Standalone fallback: overhead = prefix(4)+space(1)+size(9)+sep(3)+artifact_col+recent(9) = artifact_col+26
local fixed_width=$((artifact_col + 26))
available_width=$((terminal_width - fixed_width))
local min_width=10
if [[ $terminal_width -ge 120 ]]; then
min_width=48
elif [[ $terminal_width -ge 100 ]]; then
min_width=38
elif [[ $terminal_width -ge 80 ]]; then
min_width=25
fi
[[ $available_width -lt $min_width ]] && available_width=$min_width
[[ $available_width -gt 60 ]] && available_width=60
fi
# Truncate project path if needed
local truncated_path=$(truncate_by_display_width "$project_path" "$available_width")
local current_width=$(get_display_width "$truncated_path")
local truncated_path
truncated_path=$(truncate_by_display_width "$project_path" "$available_width")
local current_width
current_width=$(get_display_width "$truncated_path")
local char_count=${#truncated_path}
local padding=$((available_width - current_width))
local printf_width=$((char_count + padding))
# Format: "project_path size | artifact_type"
printf "%-*s %9s | %-17s" "$printf_width" "$truncated_path" "$size_str" "$artifact_type"
printf "%-*s %9s | %-*s" "$printf_width" "$truncated_path" "$size_str" "$artifact_col" "$artifact_type"
}
# Build menu options - one line per artifact
# Pass 1: collect data into parallel arrays (needed for pre-scan of widths)
local -a raw_project_paths=()
local -a raw_artifact_types=()
for item in "${safe_to_clean[@]}"; do
local project_path=$(get_project_path "$item")
local artifact_type=$(get_artifact_display_name "$item")
local project_path
project_path=$(get_project_path "$item")
local artifact_type
artifact_type=$(get_artifact_display_name "$item")
local size_raw
size_raw=$(get_dir_size_kb "$item")
local size_kb=0
@@ -1095,13 +1208,66 @@ clean_project_artifacts() {
break
fi
done
menu_options+=("$(format_purge_display "$project_path" "$artifact_type" "$size_human")")
raw_project_paths+=("$project_path")
raw_artifact_types+=("$artifact_type")
item_paths+=("$item")
item_sizes+=("$size_kb")
item_size_unknown_flags+=("$size_unknown")
item_recent_flags+=("$is_recent")
done
# Pre-scan: find max path and artifact display widths (mirrors app_selector.sh approach)
local terminal_width
terminal_width=$(tput cols 2> /dev/null || echo 80)
[[ "$terminal_width" =~ ^[0-9]+$ ]] || terminal_width=80
local max_path_display_width=0
local max_artifact_width=0
for pp in "${raw_project_paths[@]+"${raw_project_paths[@]}"}"; do
local w
w=$(get_display_width "$pp")
[[ $w -gt $max_path_display_width ]] && max_path_display_width=$w
done
for at in "${raw_artifact_types[@]+"${raw_artifact_types[@]}"}"; do
[[ ${#at} -gt $max_artifact_width ]] && max_artifact_width=${#at}
done
# Artifact column: cap at 17, floor at 6 (shortest typical names like "dist")
[[ $max_artifact_width -lt 6 ]] && max_artifact_width=6
[[ $max_artifact_width -gt 17 ]] && max_artifact_width=17
# Exact overhead: prefix(4) + space(1) + size(9) + " | "(3) + artifact_col + " | Recent"(9) = artifact_col + 26
local fixed_overhead=$((max_artifact_width + 26))
local available_for_path=$((terminal_width - fixed_overhead))
local min_path_width=10
if [[ $terminal_width -ge 120 ]]; then
min_path_width=48
elif [[ $terminal_width -ge 100 ]]; then
min_path_width=38
elif [[ $terminal_width -ge 80 ]]; then
min_path_width=25
fi
[[ $max_path_display_width -lt $min_path_width ]] && max_path_display_width=$min_path_width
[[ $available_for_path -lt $max_path_display_width ]] && max_path_display_width=$available_for_path
[[ $max_path_display_width -gt 60 ]] && max_path_display_width=60
# Ensure path width is at least 5 on very narrow terminals
[[ $max_path_display_width -lt 5 ]] && max_path_display_width=5
# Pass 2: build menu_options using pre-computed widths
for ((idx = 0; idx < ${#raw_project_paths[@]}; idx++)); do
local size_kb_val="${item_sizes[idx]}"
local size_unknown_val="${item_size_unknown_flags[idx]}"
local size_human_val=""
if [[ "$size_unknown_val" == "true" ]]; then
size_human_val="unknown"
else
size_human_val=$(bytes_to_human "$((size_kb_val * 1024))")
fi
menu_options+=("$(format_purge_display "${raw_project_paths[idx]}" "${raw_artifact_types[idx]}" "$size_human_val" "$terminal_width" "$max_path_display_width" "$max_artifact_width")")
done
# Sort by size descending (largest first) - requested in issue #311
# Use external sort for better performance with many items
if [[ ${#item_sizes[@]} -gt 0 ]]; then
@@ -1147,11 +1313,11 @@ clean_project_artifacts() {
# Set global vars for selector
export PURGE_CATEGORY_SIZES=$(
IFS=,
echo "${item_sizes[*]}"
echo "${item_sizes[*]-}"
)
export PURGE_RECENT_CATEGORIES=$(
IFS=,
echo "${item_recent_flags[*]}"
echo "${item_recent_flags[*]-}"
)
# Interactive selection (only if terminal is available)
PURGE_SELECTION_RESULT=""
@@ -1176,11 +1342,32 @@ clean_project_artifacts() {
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
return 0
fi
IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT"
local selected_total_kb=0
local selected_unknown_count=0
for idx in "${selected_indices[@]}"; do
local selected_size_kb="${item_sizes[idx]:-0}"
[[ "$selected_size_kb" =~ ^[0-9]+$ ]] || selected_size_kb=0
selected_total_kb=$((selected_total_kb + selected_size_kb))
if [[ "${item_size_unknown_flags[idx]:-false}" == "true" ]]; then
selected_unknown_count=$((selected_unknown_count + 1))
fi
done
if [[ -t 0 ]]; then
if ! confirm_purge_cleanup "${#selected_indices[@]}" "$selected_total_kb" "$selected_unknown_count"; then
echo -e "${GRAY}Purge cancelled${NC}"
printf '\n'
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
return 1
fi
fi
# Clean selected items
echo ""
IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT"
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
local cleaned_count=0
local dry_run_mode="${MOLE_DRY_RUN:-0}"
for idx in "${selected_indices[@]}"; do
local item_path="${item_paths[idx]}"
local artifact_type=$(basename "$item_path")
@@ -1200,17 +1387,27 @@ clean_project_artifacts() {
if [[ -t 1 ]]; then
start_inline_spinner "Cleaning $project_path/$artifact_type..."
fi
local removal_recorded=false
if [[ -e "$item_path" ]]; then
safe_remove "$item_path" true
if [[ ! -e "$item_path" ]]; then
local current_total=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0")
echo "$((current_total + size_kb))" > "$stats_dir/purge_stats"
((cleaned_count++))
if safe_remove "$item_path" true; then
if [[ "$dry_run_mode" == "1" || ! -e "$item_path" ]]; then
local current_total
current_total=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0")
echo "$((current_total + size_kb))" > "$stats_dir/purge_stats"
cleaned_count=$((cleaned_count + 1))
removal_recorded=true
fi
fi
fi
if [[ -t 1 ]]; then
stop_inline_spinner
echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}"
if [[ "$removal_recorded" == "true" ]]; then
if [[ "$dry_run_mode" == "1" ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} [DRY RUN] $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}"
else
echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}"
fi
fi
fi
done
# Update count

View File

@@ -5,6 +5,7 @@ set -euo pipefail
clean_deep_system() {
stop_section_spinner
local cache_cleaned=0
start_section_spinner "Cleaning system caches..."
# Optimized: Single pass for /Library/Caches (3 patterns in 1 scan)
if sudo test -d "/Library/Caches" 2> /dev/null; then
while IFS= read -r -d '' file; do
@@ -20,6 +21,7 @@ clean_deep_system() {
\( -name "*.log" -mtime "+$MOLE_LOG_AGE_DAYS" \) \
\) -print0 2> /dev/null || true)
fi
stop_section_spinner
[[ $cache_cleaned -eq 1 ]] && log_success "System caches"
start_section_spinner "Cleaning system temporary files..."
local tmp_cleaned=0
@@ -84,7 +86,7 @@ clean_deep_system() {
continue
fi
if safe_sudo_remove "$item"; then
((updates_cleaned++))
updates_cleaned=$((updates_cleaned + 1))
fi
done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
stop_section_spinner
@@ -141,28 +143,33 @@ clean_deep_system() {
debug_log "Cleaning macOS installer: $app_name, $size_human, ${age_days} days old"
if safe_sudo_remove "$installer_app"; then
log_success "$app_name, $size_human"
((installer_cleaned++))
installer_cleaned=$((installer_cleaned + 1))
fi
fi
done
stop_section_spinner
[[ $installer_cleaned -gt 0 ]] && debug_log "Cleaned $installer_cleaned macOS installer(s)"
start_section_spinner "Scanning system caches..."
start_section_spinner "Scanning browser code signature caches..."
local code_sign_cleaned=0
while IFS= read -r -d '' cache_dir; do
if safe_sudo_remove "$cache_dir"; then
((code_sign_cleaned++))
code_sign_cleaned=$((code_sign_cleaned + 1))
fi
done < <(run_with_timeout 5 command find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true)
stop_section_spinner
[[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches, $code_sign_cleaned items"
local diag_base="/private/var/db/diagnostics"
start_section_spinner "Cleaning system diagnostic logs..."
safe_sudo_find_delete "$diag_base" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
safe_sudo_find_delete "$diag_base" "*.tracev3" "30" "f" || true
safe_sudo_find_delete "/private/var/db/DiagnosticPipeline" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
stop_section_spinner
log_success "System diagnostic logs"
start_section_spinner "Cleaning power logs..."
safe_sudo_find_delete "/private/var/db/powerlog" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
stop_section_spinner
log_success "Power logs"
start_section_spinner "Cleaning memory exception reports..."
local mem_reports_dir="/private/var/db/reportmemoryexception/MemoryLimitViolations"
@@ -171,15 +178,16 @@ clean_deep_system() {
# Count and size old files before deletion
local file_count=0
local total_size_kb=0
while IFS= read -r -d '' file; do
((file_count++))
local file_size
file_size=$(sudo stat -f%z "$file" 2> /dev/null || echo "0")
((total_size_kb += file_size / 1024))
done < <(sudo find "$mem_reports_dir" -type f -mtime +30 -print0 2> /dev/null || true)
local total_bytes=0
local stats_out
stats_out=$(sudo find "$mem_reports_dir" -type f -mtime +30 -exec stat -f "%z" {} + 2> /dev/null | awk '{c++; s+=$1} END {print c+0, s+0}' || true)
if [[ -n "$stats_out" ]]; then
read -r file_count total_bytes <<< "$stats_out"
total_size_kb=$((total_bytes / 1024))
fi
if [[ "$file_count" -gt 0 ]]; then
if [[ "${DRY_RUN:-false}" != "true" ]]; then
if [[ "${DRY_RUN:-}" != "true" ]]; then
if safe_sudo_find_delete "$mem_reports_dir" "*" "30" "f"; then
mem_cleaned=1
fi
@@ -207,6 +215,11 @@ clean_time_machine_failed_backups() {
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
return 0
fi
# Fast pre-check: skip entirely if Time Machine is not configured (no tmutil needed)
if ! defaults read /Library/Preferences/com.apple.TimeMachine AutoBackup 2> /dev/null | grep -qE '^[01]$'; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
return 0
fi
start_section_spinner "Checking Time Machine configuration..."
local spinner_active=true
local tm_info
@@ -287,7 +300,7 @@ clean_time_machine_failed_backups() {
size_human=$(bytes_to_human "$((size_kb * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete backup: $backup_name${NC}, ${YELLOW}$size_human dry${NC}"
((tm_cleaned++))
tm_cleaned=$((tm_cleaned + 1))
note_activity
continue
fi
@@ -297,10 +310,10 @@ clean_time_machine_failed_backups() {
fi
if tmutil delete "$inprogress_file" 2> /dev/null; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name${NC}, ${GREEN}$size_human${NC}"
((tm_cleaned++))
((files_cleaned++))
((total_size_cleaned += size_kb))
((total_items++))
tm_cleaned=$((tm_cleaned + 1))
files_cleaned=$((files_cleaned + 1))
total_size_cleaned=$((total_size_cleaned + size_kb))
total_items=$((total_items + 1))
note_activity
else
echo -e " ${YELLOW}!${NC} Could not delete: $backup_name · try manually with sudo"
@@ -339,7 +352,7 @@ clean_time_machine_failed_backups() {
size_human=$(bytes_to_human "$((size_kb * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, ${YELLOW}$size_human dry${NC}"
((tm_cleaned++))
tm_cleaned=$((tm_cleaned + 1))
note_activity
continue
fi
@@ -348,10 +361,10 @@ clean_time_machine_failed_backups() {
fi
if tmutil delete "$inprogress_file" 2> /dev/null; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, ${GREEN}$size_human${NC}"
((tm_cleaned++))
((files_cleaned++))
((total_size_cleaned += size_kb))
((total_items++))
tm_cleaned=$((tm_cleaned + 1))
files_cleaned=$((files_cleaned + 1))
total_size_cleaned=$((total_size_cleaned + size_kb))
total_items=$((total_items + 1))
note_activity
else
echo -e " ${YELLOW}!${NC} Could not delete from bundle: $backup_name"
@@ -388,6 +401,10 @@ clean_local_snapshots() {
if ! command -v tmutil > /dev/null 2>&1; then
return 0
fi
# Fast pre-check: skip entirely if Time Machine is not configured (no tmutil needed)
if ! defaults read /Library/Preferences/com.apple.TimeMachine AutoBackup 2> /dev/null | grep -qE '^[01]$'; then
return 0
fi
start_section_spinner "Checking Time Machine status..."
local rc_running=0

View File

@@ -23,7 +23,7 @@ clean_user_essentials() {
local cleaned_count=0
while IFS= read -r -d '' item; do
if safe_remove "$item" true; then
((cleaned_count++))
cleaned_count=$((cleaned_count + 1))
fi
done < <(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
if [[ $cleaned_count -gt 0 ]]; then
@@ -76,8 +76,13 @@ _clean_mail_downloads() {
)
local count=0
local cleaned_kb=0
local spinner_active=false
for target_path in "${mail_dirs[@]}"; do
if [[ -d "$target_path" ]]; then
if [[ "$spinner_active" == "false" && -t 1 ]]; then
start_section_spinner "Cleaning old Mail attachments..."
spinner_active=true
fi
local dir_size_kb=0
dir_size_kb=$(get_path_size_kb "$target_path")
if ! [[ "$dir_size_kb" =~ ^[0-9]+$ ]]; then
@@ -95,13 +100,16 @@ _clean_mail_downloads() {
local file_size_kb
file_size_kb=$(get_path_size_kb "$file_path")
if safe_remove "$file_path" true; then
((count++))
((cleaned_kb += file_size_kb))
count=$((count + 1))
cleaned_kb=$((cleaned_kb + file_size_kb))
fi
fi
done < <(command find "$target_path" -type f -mtime +"$mail_age_days" -print0 2> /dev/null || true)
fi
done
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
fi
if [[ $count -gt 0 ]]; then
local cleaned_mb
cleaned_mb=$(echo "$cleaned_kb" | awk '{printf "%.1f", $1/1024}' || echo "0.0")
@@ -163,7 +171,7 @@ clean_chrome_old_versions() {
size_kb=$(get_path_size_kb "$dir" || echo 0)
size_kb="${size_kb:-0}"
total_size=$((total_size + size_kb))
((cleaned_count++))
cleaned_count=$((cleaned_count + 1))
cleaned_any=true
if [[ "$DRY_RUN" != "true" ]]; then
if has_sudo_session; then
@@ -183,9 +191,9 @@ clean_chrome_old_versions() {
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Chrome old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}"
fi
((files_cleaned += cleaned_count))
((total_size_cleaned += total_size))
((total_items++))
files_cleaned=$((files_cleaned + cleaned_count))
total_size_cleaned=$((total_size_cleaned + total_size))
total_items=$((total_items + 1))
note_activity
fi
}
@@ -249,7 +257,7 @@ clean_edge_old_versions() {
size_kb=$(get_path_size_kb "$dir" || echo 0)
size_kb="${size_kb:-0}"
total_size=$((total_size + size_kb))
((cleaned_count++))
cleaned_count=$((cleaned_count + 1))
cleaned_any=true
if [[ "$DRY_RUN" != "true" ]]; then
if has_sudo_session; then
@@ -269,9 +277,9 @@ clean_edge_old_versions() {
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}"
fi
((files_cleaned += cleaned_count))
((total_size_cleaned += total_size))
((total_items++))
files_cleaned=$((files_cleaned + cleaned_count))
total_size_cleaned=$((total_size_cleaned + total_size))
total_items=$((total_items + 1))
note_activity
fi
}
@@ -316,7 +324,7 @@ clean_edge_updater_old_versions() {
size_kb=$(get_path_size_kb "$dir" || echo 0)
size_kb="${size_kb:-0}"
total_size=$((total_size + size_kb))
((cleaned_count++))
cleaned_count=$((cleaned_count + 1))
cleaned_any=true
if [[ "$DRY_RUN" != "true" ]]; then
safe_remove "$dir" true > /dev/null 2>&1 || true
@@ -331,9 +339,9 @@ clean_edge_updater_old_versions() {
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge updater old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}"
fi
((files_cleaned += cleaned_count))
((total_size_cleaned += total_size))
((total_items++))
files_cleaned=$((files_cleaned + cleaned_count))
total_size_cleaned=$((total_size_cleaned + total_size))
total_items=$((total_items + 1))
note_activity
fi
}
@@ -387,6 +395,7 @@ scan_external_volumes() {
done
stop_section_spinner
}
# Finder metadata (.DS_Store).
clean_finder_metadata() {
if [[ "$PROTECT_FINDER_METADATA" == "true" ]]; then
@@ -411,14 +420,17 @@ clean_support_app_data() {
safe_find_delete "$idle_assets_dir" "*" "$support_age_days" "f" || true
fi
# Clean old aerial wallpaper videos (can be large, safe to remove).
safe_clean ~/Library/Application\ Support/com.apple.wallpaper/aerials/videos/* "Aerial wallpaper videos"
# Do not touch Messages attachments, only preview/sticker caches.
if pgrep -x "Messages" > /dev/null 2>&1; then
echo -e " ${GRAY}${ICON_WARNING}${NC} Messages is running · preview cache cleanup skipped"
return 0
else
safe_clean ~/Library/Messages/StickerCache/* "Messages sticker cache"
safe_clean ~/Library/Messages/Caches/Previews/Attachments/* "Messages preview attachment cache"
safe_clean ~/Library/Messages/Caches/Previews/StickerCache/* "Messages preview sticker cache"
fi
safe_clean ~/Library/Messages/StickerCache/* "Messages sticker cache"
safe_clean ~/Library/Messages/Caches/Previews/Attachments/* "Messages preview attachment cache"
safe_clean ~/Library/Messages/Caches/Previews/StickerCache/* "Messages preview sticker cache"
}
# App caches (merged: macOS system caches + Sandboxed apps).
@@ -472,14 +484,15 @@ clean_app_caches() {
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${GREEN}$size_human${NC}"
fi
((files_cleaned += cleaned_count))
((total_size_cleaned += total_size))
((total_items++))
files_cleaned=$((files_cleaned + cleaned_count))
total_size_cleaned=$((total_size_cleaned + total_size))
total_items=$((total_items + 1))
note_activity
fi
clean_group_container_caches
}
# Process a single container cache directory.
process_container_cache() {
local container_dir="$1"
@@ -500,9 +513,9 @@ process_container_cache() {
if find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then
local size
size=$(get_path_size_kb "$cache_dir")
((total_size += size))
total_size=$((total_size + size))
found_any=true
((cleaned_count++))
cleaned_count=$((cleaned_count + 1))
if [[ "$DRY_RUN" != "true" ]]; then
local item
while IFS= read -r -d '' item; do
@@ -600,7 +613,7 @@ clean_group_container_caches() {
item_size=$(get_path_size_kb "$item" 2> /dev/null) || item_size=0
[[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0
candidate_changed=true
((candidate_size_kb += item_size))
candidate_size_kb=$((candidate_size_kb + item_size))
done
else
for item in "${items_to_clean[@]}"; do
@@ -609,14 +622,14 @@ clean_group_container_caches() {
[[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0
if safe_remove "$item" true 2> /dev/null; then
candidate_changed=true
((candidate_size_kb += item_size))
candidate_size_kb=$((candidate_size_kb + item_size))
fi
done
fi
if [[ "$candidate_changed" == "true" ]]; then
((total_size += candidate_size_kb))
((cleaned_count++))
total_size=$((total_size + candidate_size_kb))
cleaned_count=$((cleaned_count + 1))
found_any=true
fi
done
@@ -632,12 +645,13 @@ clean_group_container_caches() {
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Group Containers logs/caches${NC}, ${GREEN}$size_human${NC}"
fi
((files_cleaned += cleaned_count))
((total_size_cleaned += total_size))
((total_items++))
files_cleaned=$((files_cleaned + cleaned_count))
total_size_cleaned=$((total_size_cleaned + total_size))
total_items=$((total_items + 1))
note_activity
fi
}
# Browser caches (Safari/Chrome/Edge/Firefox).
clean_browsers() {
safe_clean ~/Library/Caches/com.apple.Safari/* "Safari cache"
@@ -683,6 +697,7 @@ clean_browsers() {
clean_edge_old_versions
clean_edge_updater_old_versions
}
# Cloud storage caches.
clean_cloud_storage() {
safe_clean ~/Library/Caches/com.dropbox.* "Dropbox cache"
@@ -693,6 +708,7 @@ clean_cloud_storage() {
safe_clean ~/Library/Caches/com.box.desktop "Box cache"
safe_clean ~/Library/Caches/com.microsoft.OneDrive "OneDrive cache"
}
# Office app caches.
clean_office_applications() {
safe_clean ~/Library/Caches/com.microsoft.Word "Microsoft Word cache"
@@ -704,6 +720,7 @@ clean_office_applications() {
safe_clean ~/Library/Caches/org.mozilla.thunderbird/* "Thunderbird cache"
safe_clean ~/Library/Caches/com.apple.mail/* "Apple Mail cache"
}
# Virtualization caches.
clean_virtualization_tools() {
stop_section_spinner
@@ -712,6 +729,47 @@ clean_virtualization_tools() {
safe_clean ~/VirtualBox\ VMs/.cache "VirtualBox cache"
safe_clean ~/.vagrant.d/tmp/* "Vagrant temporary files"
}
# Estimate item size for Application Support cleanup.
# Files use stat; directories use du with timeout to avoid long blocking scans.
app_support_item_size_bytes() {
local item="$1"
local timeout_seconds="${2:-0.4}"
if [[ -f "$item" && ! -L "$item" ]]; then
local file_bytes
file_bytes=$(stat -f%z "$item" 2> /dev/null || echo "0")
[[ "$file_bytes" =~ ^[0-9]+$ ]] || return 1
printf '%s\n' "$file_bytes"
return 0
fi
if [[ -d "$item" && ! -L "$item" ]]; then
local du_tmp
du_tmp=$(mktemp)
local du_status=0
if run_with_timeout "$timeout_seconds" du -skP "$item" > "$du_tmp" 2> /dev/null; then
du_status=0
else
du_status=$?
fi
if [[ $du_status -ne 0 ]]; then
rm -f "$du_tmp"
return 1
fi
local size_kb
size_kb=$(awk 'NR==1 {print $1; exit}' "$du_tmp")
rm -f "$du_tmp"
[[ "$size_kb" =~ ^[0-9]+$ ]] || return 1
printf '%s\n' "$((size_kb * 1024))"
return 0
fi
return 1
}
# Application Support logs/caches.
clean_application_support_logs() {
if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" > /dev/null 2>&1; then
@@ -721,14 +779,27 @@ clean_application_support_logs() {
fi
start_section_spinner "Scanning Application Support..."
local total_size_bytes=0
local total_size_partial=false
local cleaned_count=0
local found_any=false
local size_timeout_seconds="${MOLE_APP_SUPPORT_ITEM_SIZE_TIMEOUT_SEC:-0.4}"
if [[ ! "$size_timeout_seconds" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
size_timeout_seconds=0.4
fi
# Enable nullglob for safe globbing.
local _ng_state
_ng_state=$(shopt -p nullglob || true)
shopt -s nullglob
local app_count=0
local total_apps
# Temporarily disable pipefail here so that a partial find failure (e.g. TCC
# restrictions on macOS 26+) does not propagate through the pipeline and abort
# the whole scan via set -e.
local pipefail_was_set=false
if [[ -o pipefail ]]; then
pipefail_was_set=true
set +o pipefail
fi
total_apps=$(command find "$HOME/Library/Application Support" -mindepth 1 -maxdepth 1 -type d 2> /dev/null | wc -l | tr -d ' ')
[[ "$total_apps" =~ ^[0-9]+$ ]] || total_apps=0
local last_progress_update
@@ -737,7 +808,7 @@ clean_application_support_logs() {
[[ -d "$app_dir" ]] || continue
local app_name
app_name=$(basename "$app_dir")
((app_count++))
app_count=$((app_count + 1))
update_progress_if_needed "$app_count" "$total_apps" last_progress_update 1 || true
local app_name_lower
app_name_lower=$(echo "$app_name" | LC_ALL=C tr '[:upper:]' '[:lower:]')
@@ -758,22 +829,34 @@ clean_application_support_logs() {
if [[ -d "$candidate" ]]; then
local item_found=false
local candidate_size_bytes=0
local candidate_size_partial=false
local candidate_item_count=0
while IFS= read -r -d '' item; do
[[ -e "$item" ]] || continue
item_found=true
((candidate_item_count++))
if [[ -f "$item" && ! -L "$item" ]]; then
local bytes
bytes=$(stat -f%z "$item" 2> /dev/null || echo "0")
[[ "$bytes" =~ ^[0-9]+$ ]] && ((candidate_size_bytes += bytes)) || true
candidate_item_count=$((candidate_item_count + 1))
if [[ ! -L "$item" && (-f "$item" || -d "$item") ]]; then
local item_size_bytes=""
if item_size_bytes=$(app_support_item_size_bytes "$item" "$size_timeout_seconds"); then
if [[ "$item_size_bytes" =~ ^[0-9]+$ ]]; then
candidate_size_bytes=$((candidate_size_bytes + item_size_bytes))
else
candidate_size_partial=true
fi
else
candidate_size_partial=true
fi
fi
if ((candidate_item_count % 250 == 0)); then
local current_time
current_time=$(get_epoch_seconds)
if [[ "$current_time" =~ ^[0-9]+$ ]] && ((current_time - last_progress_update >= 1)); then
local app_label="$app_name"
if [[ ${#app_label} -gt 24 ]]; then
app_label="${app_label:0:21}..."
fi
stop_section_spinner
start_section_spinner "Scanning Application Support... $app_count/$total_apps ($app_name: $candidate_item_count items)"
start_section_spinner "Scanning Application Support... $app_count/$total_apps [$app_label, $candidate_item_count items]"
last_progress_update=$current_time
fi
fi
@@ -782,8 +865,9 @@ clean_application_support_logs() {
fi
done < <(command find "$candidate" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
if [[ "$item_found" == "true" ]]; then
((total_size_bytes += candidate_size_bytes))
((cleaned_count++))
total_size_bytes=$((total_size_bytes + candidate_size_bytes))
[[ "$candidate_size_partial" == "true" ]] && total_size_partial=true
cleaned_count=$((cleaned_count + 1))
found_any=true
fi
fi
@@ -800,22 +884,34 @@ clean_application_support_logs() {
if [[ -d "$candidate" ]]; then
local item_found=false
local candidate_size_bytes=0
local candidate_size_partial=false
local candidate_item_count=0
while IFS= read -r -d '' item; do
[[ -e "$item" ]] || continue
item_found=true
((candidate_item_count++))
if [[ -f "$item" && ! -L "$item" ]]; then
local bytes
bytes=$(stat -f%z "$item" 2> /dev/null || echo "0")
[[ "$bytes" =~ ^[0-9]+$ ]] && ((candidate_size_bytes += bytes)) || true
candidate_item_count=$((candidate_item_count + 1))
if [[ ! -L "$item" && (-f "$item" || -d "$item") ]]; then
local item_size_bytes=""
if item_size_bytes=$(app_support_item_size_bytes "$item" "$size_timeout_seconds"); then
if [[ "$item_size_bytes" =~ ^[0-9]+$ ]]; then
candidate_size_bytes=$((candidate_size_bytes + item_size_bytes))
else
candidate_size_partial=true
fi
else
candidate_size_partial=true
fi
fi
if ((candidate_item_count % 250 == 0)); then
local current_time
current_time=$(get_epoch_seconds)
if [[ "$current_time" =~ ^[0-9]+$ ]] && ((current_time - last_progress_update >= 1)); then
local container_label="$container"
if [[ ${#container_label} -gt 24 ]]; then
container_label="${container_label:0:21}..."
fi
stop_section_spinner
start_section_spinner "Scanning Application Support... group container ($container: $candidate_item_count items)"
start_section_spinner "Scanning Application Support... group [$container_label, $candidate_item_count items]"
last_progress_update=$current_time
fi
fi
@@ -824,13 +920,18 @@ clean_application_support_logs() {
fi
done < <(command find "$candidate" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
if [[ "$item_found" == "true" ]]; then
((total_size_bytes += candidate_size_bytes))
((cleaned_count++))
total_size_bytes=$((total_size_bytes + candidate_size_bytes))
[[ "$candidate_size_partial" == "true" ]] && total_size_partial=true
cleaned_count=$((cleaned_count + 1))
found_any=true
fi
fi
done
done
# Restore pipefail if it was previously set
if [[ "$pipefail_was_set" == "true" ]]; then
set -o pipefail
fi
eval "$_ng_state"
stop_section_spinner
if [[ "$found_any" == "true" ]]; then
@@ -838,13 +939,21 @@ clean_application_support_logs() {
size_human=$(bytes_to_human "$total_size_bytes")
local total_size_kb=$(((total_size_bytes + 1023) / 1024))
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC}, ${YELLOW}$size_human dry${NC}"
if [[ "$total_size_partial" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC}, ${YELLOW}at least $size_human dry${NC}"
else
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC}, ${YELLOW}$size_human dry${NC}"
fi
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${GREEN}$size_human${NC}"
if [[ "$total_size_partial" == "true" ]]; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${GREEN}at least $size_human${NC}"
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${GREEN}$size_human${NC}"
fi
fi
((files_cleaned += cleaned_count))
((total_size_cleaned += total_size_kb))
((total_items++))
files_cleaned=$((files_cleaned + cleaned_count))
total_size_cleaned=$((total_size_cleaned + total_size_kb))
total_items=$((total_items + 1))
note_activity
fi
}
@@ -922,7 +1031,8 @@ check_large_file_candidates() {
fi
fi
if [[ "${SYSTEM_CLEAN:-false}" != "true" ]] && command -v tmutil > /dev/null 2>&1; then
if [[ "${SYSTEM_CLEAN:-false}" != "true" ]] && command -v tmutil > /dev/null 2>&1 &&
defaults read /Library/Preferences/com.apple.TimeMachine AutoBackup 2> /dev/null | grep -qE '^[01]$'; then
local snapshot_list snapshot_count
snapshot_list=$(run_with_timeout 3 tmutil listlocalsnapshots / 2> /dev/null || true)
if [[ -n "$snapshot_list" ]]; then

View File

@@ -334,8 +334,8 @@ readonly DATA_PROTECTED_BUNDLES=(
"*privateinternetaccess*"
# Screensaver & Wallpaper
"*Aerial*"
"*aerial*"
"*Aerial.saver*"
"com.JohnCoates.Aerial*"
"*Fliqlo*"
"*fliqlo*"
@@ -1419,6 +1419,11 @@ force_kill_app() {
local app_name="$1"
local app_path="${2:-""}"
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
debug_log "[DRY RUN] Would terminate running app: $app_name"
return 0
fi
# Get the executable name from bundle if app_path is provided
local exec_name=""
if [[ -n "$app_path" && -e "$app_path/Contents/Info.plist" ]]; then

View File

@@ -41,6 +41,28 @@ readonly ICON_DRY_RUN="→"
readonly ICON_REVIEW="☞"
readonly ICON_NAV_UP="↑"
readonly ICON_NAV_DOWN="↓"
readonly ICON_INFO=""
# ============================================================================
# LaunchServices Utility
# ============================================================================
# Locate the lsregister binary (path varies across macOS versions).
get_lsregister_path() {
local -a candidates=(
"/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
"/System/Library/CoreServices/Frameworks/LaunchServices.framework/Support/lsregister"
)
local candidate=""
for candidate in "${candidates[@]}"; do
if [[ -x "$candidate" ]]; then
echo "$candidate"
return 0
fi
done
echo ""
return 0
}
# ============================================================================
# Global Configuration Constants
@@ -166,11 +188,6 @@ is_sip_enabled() {
fi
}
# Check if running in an interactive terminal
is_interactive() {
[[ -t 1 ]]
}
# Detect CPU architecture
# Returns: "Apple Silicon" or "Intel"
detect_architecture() {
@@ -239,30 +256,6 @@ is_root_user() {
[[ "$(id -u)" == "0" ]]
}
get_user_home() {
local user="$1"
local home=""
if [[ -z "$user" ]]; then
echo ""
return 0
fi
if command -v dscl > /dev/null 2>&1; then
home=$(dscl . -read "/Users/$user" NFSHomeDirectory 2> /dev/null | awk '{print $2}' | head -1 || true)
fi
if [[ -z "$home" ]]; then
home=$(eval echo "~$user" 2> /dev/null || true)
fi
if [[ "$home" == "~"* ]]; then
home=""
fi
echo "$home"
}
get_invoking_user() {
if [[ -n "${_MOLE_INVOKING_USER_CACHE:-}" ]]; then
echo "$_MOLE_INVOKING_USER_CACHE"
@@ -311,6 +304,30 @@ get_invoking_home() {
echo "${HOME:-}"
}
get_user_home() {
local user="$1"
local home=""
if [[ -z "$user" ]]; then
echo ""
return 0
fi
if command -v dscl > /dev/null 2>&1; then
home=$(dscl . -read "/Users/$user" NFSHomeDirectory 2> /dev/null | awk '{print $2}' | head -1 || true)
fi
if [[ -z "$home" ]]; then
home=$(eval echo "~$user" 2> /dev/null || true)
fi
if [[ "$home" == "~"* ]]; then
home=""
fi
echo "$home"
}
ensure_user_dir() {
local raw_path="$1"
if [[ -z "$raw_path" ]]; then
@@ -428,35 +445,6 @@ ensure_user_file() {
# Formatting Utilities
# ============================================================================
# Convert bytes to human-readable format (e.g., 1.5GB)
bytes_to_human() {
local bytes="$1"
[[ "$bytes" =~ ^[0-9]+$ ]] || {
echo "0B"
return 1
}
# GB: >= 1073741824 bytes
if ((bytes >= 1073741824)); then
printf "%d.%02dGB\n" $((bytes / 1073741824)) $(((bytes % 1073741824) * 100 / 1073741824))
# MB: >= 1048576 bytes
elif ((bytes >= 1048576)); then
printf "%d.%01dMB\n" $((bytes / 1048576)) $(((bytes % 1048576) * 10 / 1048576))
# KB: >= 1024 bytes (round up)
elif ((bytes >= 1024)); then
printf "%dKB\n" $(((bytes + 512) / 1024))
else
printf "%dB\n" "$bytes"
fi
}
# Convert kilobytes to human-readable format
# Args: $1 - size in KB
# Returns: formatted string
bytes_to_human_kb() {
bytes_to_human "$((${1:-0} * 1024))"
}
# Get brand-friendly localized name for an application
get_brand_name() {
local name="$1"
@@ -513,6 +501,38 @@ get_brand_name() {
fi
}
# Convert bytes to human-readable format (e.g., 1.5GB)
# macOS (since Snow Leopard) uses Base-10 calculation (1 KB = 1000 bytes)
bytes_to_human() {
local bytes="$1"
[[ "$bytes" =~ ^[0-9]+$ ]] || {
echo "0B"
return 1
}
# GB: >= 1,000,000,000 bytes
if ((bytes >= 1000000000)); then
local scaled=$(((bytes * 100 + 500000000) / 1000000000))
printf "%d.%02dGB\n" $((scaled / 100)) $((scaled % 100))
# MB: >= 1,000,000 bytes
elif ((bytes >= 1000000)); then
local scaled=$(((bytes * 10 + 500000) / 1000000))
printf "%d.%01dMB\n" $((scaled / 10)) $((scaled % 10))
# KB: >= 1,000 bytes (round up to nearest KB instead of decimal)
elif ((bytes >= 1000)); then
printf "%dKB\n" $(((bytes + 500) / 1000))
else
printf "%dB\n" "$bytes"
fi
}
# Convert kilobytes to human-readable format
# Args: $1 - size in KB
# Returns: formatted string
bytes_to_human_kb() {
bytes_to_human "$((${1:-0} * 1024))"
}
# ============================================================================
# Temporary File Management
# ============================================================================
@@ -704,91 +724,6 @@ update_progress_if_needed() {
return 1
}
# ============================================================================
# Spinner Stack Management (prevents nesting issues)
# ============================================================================
# Global spinner stack
declare -a MOLE_SPINNER_STACK=()
# Push current spinner state onto stack
# Usage: push_spinner_state
push_spinner_state() {
local current_state=""
# Save current spinner PID if running
if [[ -n "${MOLE_SPINNER_PID:-}" ]] && kill -0 "$MOLE_SPINNER_PID" 2> /dev/null; then
current_state="running:$MOLE_SPINNER_PID"
else
current_state="stopped"
fi
MOLE_SPINNER_STACK+=("$current_state")
debug_log "Pushed spinner state: $current_state, stack depth: ${#MOLE_SPINNER_STACK[@]}"
}
# Pop and restore spinner state from stack
# Usage: pop_spinner_state
pop_spinner_state() {
if [[ ${#MOLE_SPINNER_STACK[@]} -eq 0 ]]; then
debug_log "Warning: Attempted to pop from empty spinner stack"
return 1
fi
# Stack depth safety check
if [[ ${#MOLE_SPINNER_STACK[@]} -gt 10 ]]; then
debug_log "Warning: Spinner stack depth excessive, ${#MOLE_SPINNER_STACK[@]}, possible leak"
fi
local last_idx=$((${#MOLE_SPINNER_STACK[@]} - 1))
local state="${MOLE_SPINNER_STACK[$last_idx]}"
# Remove from stack (Bash 3.2 compatible way)
# Instead of unset, rebuild array without last element
local -a new_stack=()
local i
for ((i = 0; i < last_idx; i++)); do
new_stack+=("${MOLE_SPINNER_STACK[$i]}")
done
MOLE_SPINNER_STACK=("${new_stack[@]}")
debug_log "Popped spinner state: $state, remaining depth: ${#MOLE_SPINNER_STACK[@]}"
# Restore state if needed
if [[ "$state" == running:* ]]; then
# Previous spinner was running - we don't restart it automatically
# This is intentional to avoid UI conflicts
:
fi
return 0
}
# Safe spinner start with stack management
# Usage: safe_start_spinner <message>
safe_start_spinner() {
local message="${1:-Working...}"
# Push current state
push_spinner_state
# Stop any existing spinner
stop_section_spinner 2> /dev/null || true
# Start new spinner
start_section_spinner "$message"
}
# Safe spinner stop with stack management
# Usage: safe_stop_spinner
safe_stop_spinner() {
# Stop current spinner
stop_section_spinner 2> /dev/null || true
# Pop previous state
pop_spinner_state || true
}
# ============================================================================
# Terminal Compatibility Checks
# ============================================================================
@@ -822,67 +757,3 @@ is_ansi_supported() {
;;
esac
}
# Get terminal capability info
# Usage: get_terminal_info
get_terminal_info() {
local info="Terminal: ${TERM:-unknown}"
if is_ansi_supported; then
info+=", ANSI supported"
if command -v tput > /dev/null 2>&1; then
local cols=$(tput cols 2> /dev/null || echo "?")
local lines=$(tput lines 2> /dev/null || echo "?")
local colors=$(tput colors 2> /dev/null || echo "?")
info+=" ${cols}x${lines}, ${colors} colors"
fi
else
info+=", ANSI not supported"
fi
echo "$info"
}
# Validate terminal environment before running
# Usage: validate_terminal_environment
# Returns: 0 if OK, 1 with warning if issues detected
validate_terminal_environment() {
local warnings=0
# Check if TERM is set
if [[ -z "${TERM:-}" ]]; then
log_warning "TERM environment variable not set"
((warnings++))
fi
# Check if running in a known problematic terminal
case "${TERM:-}" in
dumb)
log_warning "Running in 'dumb' terminal, limited functionality"
((warnings++))
;;
unknown)
log_warning "Terminal type unknown, may have display issues"
((warnings++))
;;
esac
# Check terminal size if available
if command -v tput > /dev/null 2>&1; then
local cols=$(tput cols 2> /dev/null || echo "80")
if [[ "$cols" -lt 60 ]]; then
log_warning "Terminal width, $cols cols, is narrow, output may wrap"
((warnings++))
fi
fi
# Report compatibility
if [[ $warnings -eq 0 ]]; then
debug_log "Terminal environment validated: $(get_terminal_info)"
return 0
else
debug_log "Terminal compatibility warnings: $warnings"
return 1
fi
}

View File

@@ -163,7 +163,7 @@ remove_apps_from_dock() {
local url
url=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-data:_CFURLString" "$plist" 2> /dev/null || echo "")
[[ -z "$url" ]] && {
((i++))
i=$((i + 1))
continue
}
@@ -175,7 +175,7 @@ remove_apps_from_dock() {
continue
fi
fi
((i++))
i=$((i + 1))
done
done

View File

@@ -249,6 +249,11 @@ safe_remove() {
local rm_exit=0
error_msg=$(rm -rf "$path" 2>&1) || rm_exit=$? # safe_remove
# Preserve interrupt semantics so callers can abort long-running deletions.
if [[ $rm_exit -ge 128 ]]; then
return "$rm_exit"
fi
if [[ $rm_exit -eq 0 ]]; then
# Log successful removal
log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human"
@@ -498,6 +503,19 @@ get_path_size_kb() {
echo "0"
return
}
# For .app bundles, prefer mdls logical size as it matches Finder
# (APFS clone/sparse files make 'du' severely underreport apps like Xcode)
if [[ "$path" == *.app || "$path" == *.app/ ]]; then
local mdls_size
mdls_size=$(mdls -name kMDItemLogicalSize -raw "$path" 2> /dev/null || true)
if [[ "$mdls_size" =~ ^[0-9]+$ && "$mdls_size" -gt 0 ]]; then
# Return in KB
echo "$((mdls_size / 1024))"
return
fi
fi
local size
size=$(command du -skP "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true)
@@ -518,7 +536,7 @@ calculate_total_size() {
if [[ -n "$file" && -e "$file" ]]; then
local size_kb
size_kb=$(get_path_size_kb "$file")
((total_kb += size_kb))
total_kb=$((total_kb + size_kb))
fi
done <<< "$files"

View File

@@ -18,6 +18,7 @@ show_installer_help() {
echo "Find and remove installer files (.dmg, .pkg, .iso, .xip, .zip)."
echo ""
echo "Options:"
echo " --dry-run Preview installer cleanup without making changes"
echo " --debug Show detailed operation logs"
echo " -h, --help Show this help message"
}
@@ -45,6 +46,7 @@ show_touchid_help() {
echo " status Show current Touch ID status"
echo ""
echo "Options:"
echo " --dry-run Preview Touch ID changes without modifying sudo config"
echo " -h, --help Show this help message"
echo ""
echo "If no command is provided, an interactive menu is shown."
@@ -56,6 +58,7 @@ show_uninstall_help() {
echo "Interactively remove applications and their leftover files."
echo ""
echo "Options:"
echo " --dry-run Preview app uninstallation without making changes"
echo " --debug Show detailed operation logs"
echo " -h, --help Show this help message"
}

View File

@@ -363,7 +363,12 @@ print_summary_block() {
fi
done
local divider="======================================================================"
local _tw
_tw=$(tput cols 2> /dev/null || echo 70)
[[ "$_tw" =~ ^[0-9]+$ ]] || _tw=70
[[ $_tw -gt 70 ]] && _tw=70
local divider
divider=$(printf '%*s' "$_tw" '' | tr ' ' '=')
# Print with dividers
echo ""

View File

@@ -76,7 +76,7 @@ _request_password() {
if [[ -z "$password" ]]; then
unset password
((attempts++))
attempts=$((attempts + 1))
if [[ $attempts -lt 3 ]]; then
echo -e "${GRAY}${ICON_WARNING}${NC} Password cannot be empty" > "$tty_path"
fi
@@ -91,7 +91,7 @@ _request_password() {
fi
unset password
((attempts++))
attempts=$((attempts + 1))
if [[ $attempts -lt 3 ]]; then
echo -e "${GRAY}${ICON_WARNING}${NC} Incorrect password, try again" > "$tty_path"
fi
@@ -166,7 +166,7 @@ request_sudo_access() {
break
fi
sleep 0.1
((elapsed++))
elapsed=$((elapsed + 1))
done
# Touch ID failed/cancelled - clean up thoroughly before password input
@@ -216,7 +216,7 @@ _start_sudo_keepalive() {
local retry_count=0
while true; do
if ! sudo -n -v 2> /dev/null; then
((retry_count++))
retry_count=$((retry_count + 1))
if [[ $retry_count -ge 3 ]]; then
exit 1
fi

View File

@@ -138,8 +138,8 @@ truncate_by_display_width() {
fi
truncated+="$char"
((width += char_width))
((i++))
width=$((width + char_width))
i=$((i + 1))
done
# Restore locale
@@ -265,7 +265,7 @@ read_key() {
drain_pending_input() {
local drained=0
while IFS= read -r -s -n 1 -t 0.01 _ 2> /dev/null; do
((drained++))
drained=$((drained + 1))
[[ $drained -gt 100 ]] && break
done
}
@@ -287,9 +287,40 @@ show_menu_option() {
INLINE_SPINNER_PID=""
INLINE_SPINNER_STOP_FILE=""
# Keep spinner message on one line and avoid wrapping/noisy output on narrow terminals.
format_spinner_message() {
local message="$1"
message="${message//$'\r'/ }"
message="${message//$'\n'/ }"
local cols=80
if command -v tput > /dev/null 2>&1; then
cols=$(tput cols 2> /dev/null || echo "80")
fi
[[ "$cols" =~ ^[0-9]+$ ]] || cols=80
# Reserve space for prefix + spinner char + spacing.
local available=$((cols - 8))
if [[ $available -lt 20 ]]; then
available=20
fi
if [[ ${#message} -gt $available ]]; then
if [[ $available -gt 3 ]]; then
message="${message:0:$((available - 3))}..."
else
message="${message:0:$available}"
fi
fi
printf "%s" "$message"
}
start_inline_spinner() {
stop_inline_spinner 2> /dev/null || true
local message="$1"
local display_message
display_message=$(format_spinner_message "$message")
if [[ -t 1 ]]; then
# Create unique stop flag file for this spinner instance
@@ -309,8 +340,8 @@ start_inline_spinner() {
while [[ ! -f "$stop_file" ]]; do
local c="${chars:$((i % ${#chars})):1}"
# Output to stderr to avoid interfering with stdout
printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$message" >&2 || break
((i++))
printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$display_message" >&2 || break
i=$((i + 1))
sleep 0.05
done
@@ -321,7 +352,7 @@ start_inline_spinner() {
INLINE_SPINNER_PID=$!
disown "$INLINE_SPINNER_PID" 2> /dev/null || true
else
echo -n " ${BLUE}|${NC} $message" >&2 || true
echo -n " ${BLUE}|${NC} $display_message" >&2 || true
fi
}
@@ -336,7 +367,7 @@ stop_inline_spinner() {
local wait_count=0
while kill -0 "$INLINE_SPINNER_PID" 2> /dev/null && [[ $wait_count -lt 5 ]]; do
sleep 0.05 2> /dev/null || true
((wait_count++))
wait_count=$((wait_count + 1))
done
# Only use SIGKILL as last resort if process is stuck
@@ -356,20 +387,6 @@ stop_inline_spinner() {
fi
}
# Run command with a terminal spinner
with_spinner() {
local msg="$1"
shift || true
local timeout=180
start_inline_spinner "$msg"
local exit_code=0
if [[ -n "${MOLE_TIMEOUT_BIN:-}" ]]; then
"$MOLE_TIMEOUT_BIN" "$timeout" "$@" > /dev/null 2>&1 || exit_code=$?
else "$@" > /dev/null 2>&1 || exit_code=$?; fi
stop_inline_spinner "$msg"
return $exit_code
}
# Get spinner characters
mo_spinner_chars() {
local chars="|/-\\"

View File

@@ -138,7 +138,7 @@ perform_auto_fix() {
echo -e "${BLUE}Enabling Firewall...${NC}"
if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then
echo -e "${GREEN}${NC} Firewall enabled"
((fixed_count++))
fixed_count=$((fixed_count + 1))
fixed_items+=("Firewall enabled")
else
echo -e "${RED}${NC} Failed to enable Firewall"
@@ -154,7 +154,7 @@ perform_auto_fix() {
auth sufficient pam_tid.so
' '$pam_file'" 2> /dev/null; then
echo -e "${GREEN}${NC} Touch ID configured"
((fixed_count++))
fixed_count=$((fixed_count + 1))
fixed_items+=("Touch ID configured for sudo")
else
echo -e "${RED}${NC} Failed to configure Touch ID"
@@ -167,7 +167,7 @@ auth sufficient pam_tid.so
echo -e "${BLUE}Installing Rosetta 2...${NC}"
if sudo softwareupdate --install-rosetta --agree-to-license 2>&1 | grep -qE "(Installing|Installed|already installed)"; then
echo -e "${GREEN}${NC} Rosetta 2 installed"
((fixed_count++))
fixed_count=$((fixed_count + 1))
fixed_items+=("Rosetta 2 installed")
else
echo -e "${RED}${NC} Failed to install Rosetta 2"

View File

@@ -70,7 +70,7 @@ manage_purge_paths() {
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" || "$line" =~ ^# ]] && continue
((custom_count++))
custom_count=$((custom_count + 1))
done < "$PURGE_PATHS_CONFIG"
fi

View File

@@ -117,7 +117,7 @@ perform_updates() {
if "$mole_bin" update 2>&1 | grep -qE "(Updated|latest version)"; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Mole updated"
reset_mole_cache
((updated_count++))
updated_count=$((updated_count + 1))
else
echo -e "${RED}${NC} Mole update failed"
fi

View File

@@ -302,7 +302,7 @@ ${GRAY}Edit: ${display_config}${NC}"
cache_patterns+=("$pattern")
menu_options+=("$display_name")
((index++)) || true
index=$((index + 1))
done <<< "$items_source"
# Identify custom patterns (not in predefined list)

View File

@@ -25,7 +25,7 @@ fix_broken_preferences() {
plutil -lint "$plist_file" > /dev/null 2>&1 && continue
safe_remove "$plist_file" true > /dev/null 2>&1 || true
((broken_count++))
broken_count=$((broken_count + 1))
done < <(command find "$prefs_dir" -maxdepth 1 -name "*.plist" -type f 2> /dev/null || true)
# Check ByHost preferences.
@@ -45,7 +45,7 @@ fix_broken_preferences() {
plutil -lint "$plist_file" > /dev/null 2>&1 && continue
safe_remove "$plist_file" true > /dev/null 2>&1 || true
((broken_count++))
broken_count=$((broken_count + 1))
done < <(command find "$byhost_dir" -name "*.plist" -type f 2> /dev/null || true)
fi

View File

@@ -14,7 +14,7 @@ opt_msg() {
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $message"
else
echo -e " ${GREEN}${NC} $message"
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $message"
fi
}
@@ -314,7 +314,7 @@ opt_sqlite_vacuum() {
local file_size
file_size=$(get_file_size "$db_file")
if [[ "$file_size" -gt "$MOLE_SQLITE_MAX_SIZE" ]]; then
((skipped++))
skipped=$((skipped + 1))
continue
fi
@@ -327,7 +327,7 @@ opt_sqlite_vacuum() {
freelist_count=$(echo "$page_info" | awk 'NR==2 {print $1}' 2> /dev/null || echo "")
if [[ "$page_count" =~ ^[0-9]+$ && "$freelist_count" =~ ^[0-9]+$ && "$page_count" -gt 0 ]]; then
if ((freelist_count * 100 < page_count * 5)); then
((skipped++))
skipped=$((skipped + 1))
continue
fi
fi
@@ -341,7 +341,7 @@ opt_sqlite_vacuum() {
set -e
if [[ $integrity_status -ne 0 ]] || ! echo "$integrity_check" | grep -q "ok"; then
((skipped++))
skipped=$((skipped + 1))
continue
fi
fi
@@ -354,14 +354,14 @@ opt_sqlite_vacuum() {
set -e
if [[ $exit_code -eq 0 ]]; then
((vacuumed++))
vacuumed=$((vacuumed + 1))
elif [[ $exit_code -eq 124 ]]; then
((timed_out++))
timed_out=$((timed_out + 1))
else
((failed++))
failed=$((failed + 1))
fi
else
((vacuumed++))
vacuumed=$((vacuumed + 1))
fi
done < <(compgen -G "$pattern" || true)
done
@@ -406,9 +406,10 @@ opt_launch_services_rebuild() {
start_inline_spinner ""
fi
local lsregister="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
local lsregister
lsregister=$(get_lsregister_path)
if [[ -f "$lsregister" ]]; then
if [[ -n "$lsregister" ]]; then
local success=0
if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
@@ -729,7 +730,7 @@ opt_spotlight_index_optimize() {
test_end=$(get_epoch_seconds)
test_duration=$((test_end - test_start))
if [[ $test_duration -gt 3 ]]; then
((slow_count++))
slow_count=$((slow_count + 1))
fi
sleep 1
done
@@ -741,7 +742,7 @@ opt_spotlight_index_optimize() {
fi
if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
echo -e " ${BLUE}${NC} Spotlight search is slow, rebuilding index, may take 1-2 hours"
echo -e " ${BLUE}${ICON_INFO}${NC} Spotlight search is slow, rebuilding index, may take 1-2 hours"
if sudo mdutil -E / > /dev/null 2>&1; then
opt_msg "Spotlight index rebuild started"
echo -e " ${GRAY}Indexing will continue in background${NC}"

View File

@@ -133,7 +133,7 @@ select_apps_for_uninstall() {
sizekb_csv+=",${size_kb:-0}"
fi
names_arr+=("$display_name")
((idx++))
idx=$((idx + 1))
done
# Use newline separator for names (safe for names with commas)
local names_newline

View File

@@ -155,7 +155,7 @@ paginated_multi_select() {
# Only count if not already selected (handles duplicates)
if [[ ${selected[idx]} != true ]]; then
selected[idx]=true
((selected_count++))
selected_count=$((selected_count + 1))
fi
fi
done
@@ -654,7 +654,7 @@ paginated_multi_select() {
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
local old_cursor=$cursor_pos
((cursor_pos++))
cursor_pos=$((cursor_pos + 1))
local new_cursor=$cursor_pos
if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then
@@ -674,7 +674,7 @@ paginated_multi_select() {
prev_cursor_pos=$cursor_pos
continue
elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then
((top_index++))
top_index=$((top_index + 1))
visible_count=$((${#view_indices[@]} - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -ge $visible_count ]]; then
@@ -716,7 +716,7 @@ paginated_multi_select() {
((selected_count--))
else
selected[real]=true
((selected_count++))
selected_count=$((selected_count + 1))
fi
# Incremental update: only redraw header (for count) and current row
@@ -757,9 +757,9 @@ paginated_multi_select() {
local visible_count=$((${#view_indices[@]} - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
((cursor_pos++))
cursor_pos=$((cursor_pos + 1))
elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then
((top_index++))
top_index=$((top_index + 1))
fi
need_full_redraw=true
fi
@@ -843,7 +843,7 @@ paginated_multi_select() {
if [[ $idx -lt ${#view_indices[@]} ]]; then
local real="${view_indices[idx]}"
selected[real]=true
((selected_count++))
selected_count=$((selected_count + 1))
fi
fi

View File

@@ -159,7 +159,7 @@ paginated_multi_select() {
# Count selections for header display
local selected_count=0
for ((i = 0; i < total_items; i++)); do
[[ ${selected[i]} == true ]] && ((selected_count++))
[[ ${selected[i]} == true ]] && selected_count=$((selected_count + 1))
done
# Header
@@ -247,9 +247,9 @@ paginated_multi_select() {
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
((cursor_pos++))
cursor_pos=$((cursor_pos + 1))
elif [[ $((top_index + visible_count)) -lt $total_items ]]; then
((top_index++))
top_index=$((top_index + 1))
visible_count=$((total_items - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -ge $visible_count ]]; then

View File

@@ -15,6 +15,10 @@ get_lsregister_path() {
echo "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
}
is_uninstall_dry_run() {
[[ "${MOLE_DRY_RUN:-0}" == "1" ]]
}
# High-performance sensitive data detection (pure Bash, no subprocess)
# Faster than grep for batch operations, especially when processing many apps
has_sensitive_data() {
@@ -81,6 +85,11 @@ stop_launch_services() {
local bundle_id="$1"
local has_system_files="${2:-false}"
if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would unload launch services for bundle: $bundle_id"
return 0
fi
[[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0
# Validate bundle_id format: must be reverse-DNS style (e.g., com.example.app)
@@ -156,6 +165,11 @@ remove_login_item() {
local app_name="$1"
local bundle_id="$2"
if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would remove login item: ${app_name:-$bundle_id}"
return 0
fi
# Skip if no identifiers provided
[[ -z "$app_name" && -z "$bundle_id" ]] && return 0
@@ -205,7 +219,12 @@ remove_file_list() {
safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true
else
if [[ "$use_sudo" == "true" ]]; then
safe_sudo_remove "$file" && ((++count)) || true
if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would sudo remove: $file"
((++count))
else
safe_sudo_remove "$file" && ((++count)) || true
fi
else
safe_remove "$file" true && ((++count)) || true
fi
@@ -321,7 +340,7 @@ batch_uninstall_applications() {
local system_size_kb=$(calculate_total_size "$system_files" || echo "0")
local diag_system_size_kb=$(calculate_total_size "$diag_system" || echo "0")
local total_kb=$((app_size_kb + related_size_kb + system_size_kb + diag_system_size_kb))
((total_estimated_size += total_kb)) || true
total_estimated_size=$((total_estimated_size + total_kb))
# shellcheck disable=SC2128
if [[ -n "$system_files" || -n "$diag_system" ]]; then
@@ -441,7 +460,7 @@ batch_uninstall_applications() {
export MOLE_UNINSTALL_MODE=1
# Request sudo if needed.
if [[ ${#sudo_apps[@]} -gt 0 ]]; then
if [[ ${#sudo_apps[@]} -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then
if ! sudo -n true 2> /dev/null; then
if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then
echo ""
@@ -469,7 +488,7 @@ batch_uninstall_applications() {
local -a success_items=()
local current_index=0
for detail in "${app_details[@]}"; do
((current_index++))
current_index=$((current_index + 1))
IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name encoded_diag_system <<< "$detail"
local related_files=$(decode_file_list "$encoded_files" "$app_name")
local system_files=$(decode_file_list "$encoded_system_files" "$app_name")
@@ -551,12 +570,18 @@ batch_uninstall_applications() {
fi
fi
else
local ret=0
safe_sudo_remove "$app_path" || ret=$?
if [[ $ret -ne 0 ]]; then
local diagnosis
diagnosis=$(diagnose_removal_failure "$ret" "$app_name")
IFS='|' read -r reason suggestion <<< "$diagnosis"
if is_uninstall_dry_run; then
if ! safe_remove "$app_path" true; then
reason="dry-run path validation failed"
fi
else
local ret=0
safe_sudo_remove "$app_path" || ret=$?
if [[ $ret -ne 0 ]]; then
local diagnosis
diagnosis=$(diagnose_removal_failure "$ret" "$app_name")
IFS='|' read -r reason suggestion <<< "$diagnosis"
fi
fi
fi
else
@@ -587,10 +612,14 @@ batch_uninstall_applications() {
remove_file_list "$system_all" "true" > /dev/null
fi
# Clean up macOS defaults (preference domains).
# Defaults writes are side effects that should never run in dry-run mode.
if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then
if defaults read "$bundle_id" &> /dev/null; then
defaults delete "$bundle_id" 2> /dev/null || true
if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would clear defaults domain: $bundle_id"
else
if defaults read "$bundle_id" &> /dev/null; then
defaults delete "$bundle_id" 2> /dev/null || true
fi
fi
# ByHost preferences (machine-specific).
@@ -614,11 +643,11 @@ batch_uninstall_applications() {
fi
fi
((total_size_freed += total_kb))
((success_count++))
[[ "$used_brew_successfully" == "true" ]] && ((brew_apps_removed++))
((files_cleaned++))
((total_items++))
total_size_freed=$((total_size_freed + total_kb))
success_count=$((success_count + 1))
[[ "$used_brew_successfully" == "true" ]] && brew_apps_removed=$((brew_apps_removed + 1))
files_cleaned=$((files_cleaned + 1))
total_items=$((total_items + 1))
success_items+=("$app_path")
else
if [[ -t 1 ]]; then
@@ -632,7 +661,7 @@ batch_uninstall_applications() {
fi
fi
((failed_count++))
failed_count=$((failed_count + 1))
failed_items+=("$app_name:$reason:${suggestion:-}")
fi
done
@@ -648,8 +677,15 @@ batch_uninstall_applications() {
local success_text="app"
[[ $success_count -gt 1 ]] && success_text="apps"
local success_line="Removed ${success_count} ${success_text}"
if is_uninstall_dry_run; then
success_line="Would remove ${success_count} ${success_text}"
fi
if [[ -n "$freed_display" ]]; then
success_line+=", freed ${GREEN}${freed_display}${NC}"
if is_uninstall_dry_run; then
success_line+=", would free ${GREEN}${freed_display}${NC}"
else
success_line+=", freed ${GREEN}${freed_display}${NC}"
fi
fi
# Format app list with max 3 per line.
@@ -676,7 +712,7 @@ batch_uninstall_applications() {
else
current_line="$current_line, $display_item"
fi
((idx++))
idx=$((idx + 1))
done
if [[ -n "$current_line" ]]; then
summary_details+=("$current_line")
@@ -734,6 +770,9 @@ batch_uninstall_applications() {
if [[ "$summary_status" == "warn" ]]; then
title="Uninstall incomplete"
fi
if is_uninstall_dry_run; then
title="Uninstall dry run complete"
fi
echo ""
print_summary_block "$title" "${summary_details[@]}"
@@ -741,30 +780,38 @@ batch_uninstall_applications() {
# Auto-run brew autoremove if Homebrew casks were uninstalled
if [[ $brew_apps_removed -gt 0 ]]; then
# Show spinner while checking for orphaned dependencies
if [[ -t 1 ]]; then
start_inline_spinner "Checking brew dependencies..."
fi
if is_uninstall_dry_run; then
log_info "[DRY RUN] Would run brew autoremove"
else
# Show spinner while checking for orphaned dependencies
if [[ -t 1 ]]; then
start_inline_spinner "Checking brew dependencies..."
fi
local autoremove_output removed_count
autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2> /dev/null) || true
removed_count=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" || true)
removed_count=${removed_count:-0}
local autoremove_output removed_count
autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2> /dev/null) || true
removed_count=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" || true)
removed_count=${removed_count:-0}
if [[ -t 1 ]]; then
stop_inline_spinner
fi
if [[ -t 1 ]]; then
stop_inline_spinner
fi
if [[ $removed_count -gt 0 ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Cleaned $removed_count orphaned brew dependencies"
echo ""
if [[ $removed_count -gt 0 ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Cleaned $removed_count orphaned brew dependencies"
echo ""
fi
fi
fi
# Clean up Dock entries for uninstalled apps.
if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then
remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true
refresh_launch_services_after_uninstall 2> /dev/null || true
if is_uninstall_dry_run; then
log_info "[DRY RUN] Would refresh LaunchServices and update Dock entries"
else
remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true
refresh_launch_services_after_uninstall 2> /dev/null || true
fi
fi
_cleanup_sudo_keepalive
@@ -775,6 +822,6 @@ batch_uninstall_applications() {
_restore_uninstall_traps
unset -f _restore_uninstall_traps
((total_size_cleaned += total_size_freed))
total_size_cleaned=$((total_size_cleaned + total_size_freed))
unset failed_items
}

View File

@@ -168,6 +168,11 @@ brew_uninstall_cask() {
local cask_name="$1"
local app_path="${2:-}"
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
debug_log "[DRY RUN] Would brew uninstall --cask --zap $cask_name"
return 0
fi
is_homebrew_available || return 1
[[ -z "$cask_name" ]] && return 1