#!/bin/bash # Mole - Installer command # Find and remove installer files - .dmg, .pkg, .mpkg, .iso, .xip, .zip set -euo pipefail # User state and installed tools must never run with inherited root privileges. # Individual maintenance operations request administrator access themselves. if [[ "$EUID" -eq 0 ]]; then printf '%s\n' 'Run Mole without sudo; it requests administrator access when needed.' >&2 exit 1 fi # shellcheck disable=SC2154 # External variables set by menu_paginated.sh and environment declare MOLE_SELECTION_RESULT declare MOLE_INSTALLER_SCAN_MAX_DEPTH export LC_ALL=C export LANG=C export MOLE_CURRENT_COMMAND="${MOLE_CURRENT_COMMAND:-installer}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/core/common.sh" source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh" cleanup() { if [[ "${IN_ALT_SCREEN:-0}" == "1" ]]; then leave_alt_screen IN_ALT_SCREEN=0 fi show_cursor cleanup_temp_files } trap cleanup EXIT trap 'trap - EXIT; cleanup; exit 130' INT TERM # Scan configuration readonly INSTALLER_SCAN_MAX_DEPTH_DEFAULT=2 readonly INSTALLER_SCAN_PATHS=( "$HOME/Downloads" "$HOME/Desktop" "$HOME/Documents" "$HOME/Public" "$HOME/Library/Downloads" "/Users/Shared" "/Users/Shared/Downloads" "$HOME/Library/Caches/Homebrew" "$HOME/Library/Mobile Documents/com~apple~CloudDocs/Downloads" "$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads" "$HOME/Library/Application Support/Telegram Desktop" "$HOME/Downloads/Telegram Desktop" ) readonly MAX_ZIP_ENTRIES=50 readonly INSTALLER_EXIT_INCOMPLETE=3 readonly INSTALLER_EXIT_SCAN_FAILED=4 INSTALLER_SCAN_FAILURE_PATH="" ZIP_LIST_CMD=() IN_ALT_SCREEN=0 if command -v zipinfo > /dev/null 2>&1; then ZIP_LIST_CMD=(zipinfo -1) elif command -v unzip > /dev/null 2>&1; then ZIP_LIST_CMD=(unzip -Z -1) fi TERMINAL_WIDTH=0 # Inspect the first N entries only after a complete ZIP listing. # Return 0 for an installer, 1 for an ordinary/unreadable/corrupt ZIP; # resource failures and timeout/signal statuses abort discovery. is_installer_zip() { local zip="$1" local deadline="${2:-$((SECONDS + 10#${MOLE_TIMEOUT_DISK_VERIFY_SEC%%.*}))}" local listing duration rc=0 [[ ${#ZIP_LIST_CMD[@]} -gt 0 ]] || return 1 duration=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_SHORT_QUERY_SEC" "$deadline") || return $? listing=$(create_temp_file) || return "$INSTALLER_EXIT_SCAN_FAILED" run_with_timeout "$duration" "${ZIP_LIST_CMD[@]}" "$zip" > "$listing" 2> /dev/null || rc=$? if [[ $rc -eq 0 ]]; then duration=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_SHORT_QUERY_SEC" "$deadline") || rc=$? if [[ $rc -eq 0 ]]; then run_with_timeout "$duration" awk -v cap="$MAX_ZIP_ENTRIES" ' NR <= cap && /\.(app|pkg|dmg|xip)(\/|$)/ { found=1 } END { exit found ? 0 : 1 } ' "$listing" || rc=$? fi elif ! mole_rc_timeout_or_signal "$rc"; then rc=1 # Corrupt or unreadable archives remain ordinary non-candidates. fi rm -f "$listing" # SAFE: tracked mktemp file created by this archive inspection return "$rc" } handle_candidate_file() { local file="$1" [[ -L "$file" ]] && return 0 # Skip symlinks explicitly case "$file" in *.dmg | *.pkg | *.mpkg | *.iso | *.xip) printf '%s\0' "$file" ;; *.zip) [[ -r "$file" ]] || return 0 local zip_rc=0 is_installer_zip "$file" "${2:-}" 2> /dev/null || zip_rc=$? case "$zip_rc" in 0) printf '%s\0' "$file" ;; 1) return 0 ;; *) # One slow archive (an iCloud file that has to download # first, or a huge listing) is skipped like an unreadable # one. Only the shared deadline or a signal stops the scan. if mole_rc_timeout "$zip_rc" && _mole_timeout_with_deadline 1 "${2:-}" > /dev/null; then return 0 fi return "$zip_rc" ;; esac ;; esac } # True when every diagnostic line is a permission refusal: find's # "Permission denied" / "Operation not permitted" and fd's same text with an # "(os error N)" suffix. Anything else fails the scan. installer_scan_errors_are_permission_only() { ! grep -E -v -e '(Permission denied|Operation not permitted)( \(os error [0-9]+\))?\.?$' -e '^$' "$1" > /dev/null } # Publish NUL-delimited candidates only on success; preserve producer failures. scan_installers_in_path() { local path="$1" local max_depth="${MOLE_INSTALLER_SCAN_MAX_DEPTH:-$INSTALLER_SCAN_MAX_DEPTH_DEFAULT}" local deadline="${2:-$((SECONDS + 10#${MOLE_TIMEOUT_DISK_VERIFY_SEC%%.*}))}" [[ -d "$path" ]] || return 0 # A failed producer must never publish its partial candidate prefix. local scan_file filtered_file errors_file file scan_timeout scan_rc=0 scan_file=$(create_temp_file) || return 1 filtered_file=$(create_temp_file) || return 1 errors_file=$(create_temp_file) || return 1 scan_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" "$deadline") || scan_rc=$? if [[ $scan_rc -eq 0 ]]; then local -a scan_command=() if command -v fd > /dev/null 2>&1; then scan_command=(fd --show-errors --print0 --no-ignore --hidden --type f --max-depth "$max_depth" -e dmg -e pkg -e mpkg -e iso -e xip -e zip . "$path") else scan_command=(find -H "$path" -maxdepth "$max_depth" -type f \( -name '*.dmg' -o -name '*.pkg' -o -name '*.mpkg' -o -name '*.iso' -o -name '*.xip' -o -name '*.zip' \) -print0) fi # Redirect only the producer's diagnostics; the timeout supervisor owns # its stderr, including debug traces. exec preserves the producer status. # shellcheck disable=SC2016 # This script is evaluated by the child shell. run_with_timeout "$scan_timeout" /bin/bash -c ' errors_file="$1" shift exec "$@" 2> "$errors_file" ' bash "$errors_file" "${scan_command[@]}" > "$scan_file" || scan_rc=$? fi # A folder the user cannot read (chmod 000, or privacy-protected without # Full Disk Access) is out of reach, not a failed scan: its readable # siblings still publish, and every removal is bound to its confirmed # identity anyway. find exits 1 on it while fd still exits 0, so read the # diagnostics; any other diagnostic, or any other status, keeps the whole # inventory unpublished. if [[ -s "$errors_file" ]] && [[ $scan_rc -eq 0 || $scan_rc -eq 1 ]]; then if installer_scan_errors_are_permission_only "$errors_file"; then scan_rc=0 else scan_rc=$INSTALLER_EXIT_SCAN_FAILED fi fi if [[ $scan_rc -eq 0 ]]; then while IFS= read -r -d '' file; do _mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" "$deadline" > /dev/null || { scan_rc=$? break } handle_candidate_file "$file" "$deadline" >> "$filtered_file" || { scan_rc=$? break } done < "$scan_file" fi if [[ $scan_rc -eq 0 ]]; then _mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" "$deadline" > /dev/null || scan_rc=$? fi if [[ $scan_rc -eq 0 ]]; then cat "$filtered_file" || scan_rc=$? fi rm -f "$scan_file" "$filtered_file" "$errors_file" # SAFE: tracked mktemp files created by this scan if [[ $scan_rc -ne 0 ]]; then INSTALLER_SCAN_FAILURE_PATH="$path" fi return "$scan_rc" } scan_all_installers() { local deadline="${1:-$((SECONDS + 10#${MOLE_TIMEOUT_DISK_VERIFY_SEC%%.*}))}" local path for path in "${INSTALLER_SCAN_PATHS[@]}"; do scan_installers_in_path "$path" "$deadline" || return $? done } # Initialize stats declare -i total_deleted=0 declare -i total_size_freed_kb=0 declare -i total_delete_failed=0 # Global arrays for installer data declare -a INSTALLER_PATHS=() declare -a INSTALLER_SIZES=() declare -a INSTALLER_SOURCES=() declare -a DISPLAY_NAMES=() declare -a INSTALLER_DELETE_PATHS=() declare -a INSTALLER_DELETE_SIZES=() declare -a INSTALLER_DELETE_IDENTITIES=() declare -a INSTALLER_DELETE_FAILURES=() # Get source directory display name - for example "Downloads" or "Desktop" get_source_display() { local file_path="$1" local dir_path="${file_path%/*}" # Match against known paths and return friendly names case "$dir_path" in "$HOME/Downloads"*) echo "Downloads" ;; "$HOME/Desktop"*) echo "Desktop" ;; "$HOME/Documents"*) echo "Documents" ;; "$HOME/Public"*) echo "Public" ;; "$HOME/Library/Downloads"*) echo "Library" ;; "/Users/Shared"*) echo "Shared" ;; "$HOME/Library/Caches/Homebrew"*) echo "Homebrew" ;; "$HOME/Library/Mobile Documents/com~apple~CloudDocs/Downloads"*) echo "iCloud" ;; "$HOME/Library/Containers/com.apple.mail"*) echo "Mail" ;; *"Telegram Desktop"*) echo "Telegram" ;; *) echo "${dir_path##*/}" ;; esac } get_terminal_width() { if [[ $TERMINAL_WIDTH -le 0 ]]; then TERMINAL_WIDTH=$(tput cols 2> /dev/null || echo 80) fi echo "$TERMINAL_WIDTH" } # Escape terminal controls without changing the filesystem path. installer_display_text() { local text="$1" if [[ "$text" =~ [[:cntrl:]] ]]; then printf -v text '%q' "$text" fi printf '%s' "$text" } # Format installer display with alignment - similar to purge command format_installer_display() { local filename="$1" local size_str="$2" local source="$3" filename=$(installer_display_text "$filename") source=$(installer_display_text "$source") # Terminal width for alignment local terminal_width terminal_width=$(get_terminal_width) local fixed_width=24 # Reserve for size and source local available_width=$((terminal_width - fixed_width)) # Bounds check: 20-40 chars for filename [[ $available_width -lt 20 ]] && available_width=20 [[ $available_width -gt 40 ]] && available_width=40 # Truncate filename if needed local truncated_name truncated_name=$(truncate_by_display_width "$filename" "$available_width") local current_width current_width=$(get_display_width "$truncated_name") # Get byte count for printf width calculation local old_lc="${LC_ALL:-}" export LC_ALL=C local byte_count=${#truncated_name} if [[ -n "$old_lc" ]]; then export LC_ALL="$old_lc" else unset LC_ALL fi local padding=$((available_width - current_width)) local printf_width=$((byte_count + padding)) # Format: "filename size | source" printf "%-*s %8s | %-10s" "$printf_width" "$truncated_name" "$size_str" "$source" } # Publish complete installer metadata: 0 success, 1 empty, 4 scan failure, # or the original timeout/signal status. No failure publishes partial arrays. collect_installers() { # Clear previous results INSTALLER_PATHS=() INSTALLER_SIZES=() INSTALLER_SOURCES=() DISPLAY_NAMES=() INSTALLER_SCAN_FAILURE_PATH="" # Scan all paths, deduplicate, and sort results local -a all_files=() sizes=() sources=() displays=() local deadline=$((SECONDS + 10#${MOLE_TIMEOUT_DISK_VERIFY_SEC%%.*})) local scan_file sorted_file file scan_timeout scan_rc=0 scan_file=$(create_temp_file) || return "$INSTALLER_EXIT_SCAN_FAILED" sorted_file=$(create_temp_file) || return "$INSTALLER_EXIT_SCAN_FAILED" # Start scanning with spinner if [[ -t 1 ]]; then start_inline_spinner "Scanning for installers..." fi # Start debug session debug_operation_start "Collect Installers" "Scanning for redundant installer files" scan_all_installers "$deadline" > "$scan_file" || scan_rc=$? if [[ $scan_rc -eq 0 ]]; then scan_timeout=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" "$deadline") || scan_rc=$? if [[ $scan_rc -eq 0 ]]; then run_with_timeout "$scan_timeout" sort -zu "$scan_file" > "$sorted_file" || scan_rc=$? fi fi if [[ $scan_rc -ne 0 ]]; then rm -f "$scan_file" "$sorted_file" # SAFE: tracked mktemp files created by this collection [[ ! -t 1 ]] || stop_inline_spinner if mole_rc_timeout_or_signal "$scan_rc"; then return "$scan_rc" fi return "$INSTALLER_EXIT_SCAN_FAILED" fi while IFS= read -r -d '' file; do [[ -z "$file" ]] && continue all_files+=("$file") debug_file_action "Found installer" "$file" done < "$sorted_file" rm -f "$scan_file" "$sorted_file" # SAFE: tracked mktemp files created by this collection if [[ -t 1 ]]; then stop_inline_spinner fi if [[ ${#all_files[@]} -eq 0 ]]; then if [[ "${IN_ALT_SCREEN:-0}" != "1" ]]; then echo -e "${GREEN}${ICON_SUCCESS}${NC} No installer files to clean" fi return 1 fi # Calculate sizes with spinner if [[ -t 1 ]]; then start_inline_spinner "Calculating sizes..." fi # Process each installer for file in "${all_files[@]}"; do # Calculate file size local file_size file_size=$(installer_file_size_bytes "$file" "$deadline") || { scan_rc=$? INSTALLER_SCAN_FAILURE_PATH="$file" break } # Get source directory local source source=$(get_source_display "$file") # Format human readable size local size_human size_human=$(bytes_to_human "$file_size") # Get display filename - strip Homebrew hash prefix if present local display_name display_name="${file##*/}" if [[ "$source" == "Homebrew" ]]; then # Homebrew names often look like: sha256--name--version # Strip the leading hash if it matches [0-9a-f]{64}-- if [[ "$display_name" =~ ^[0-9a-f]{64}--(.*) ]]; then display_name="${BASH_REMATCH[1]}" fi fi # Format display with alignment local display display=$(format_installer_display "$display_name" "$size_human" "$source") # Store installer data in parallel arrays sizes+=("$file_size") sources+=("$source") displays+=("$display") done if [[ -t 1 ]]; then stop_inline_spinner fi if [[ $scan_rc -eq 0 ]]; then _mole_timeout_with_deadline "$MOLE_TIMEOUT_DISK_VERIFY_SEC" "$deadline" > /dev/null || scan_rc=$? fi if [[ $scan_rc -ne 0 ]]; then mole_rc_timeout_or_signal "$scan_rc" && return "$scan_rc" return "$INSTALLER_EXIT_SCAN_FAILED" fi # Publish the parallel arrays together only after every metadata probe finishes. INSTALLER_PATHS=("${all_files[@]}") INSTALLER_SIZES=("${sizes[@]}") INSTALLER_SOURCES=("${sources[@]}") DISPLAY_NAMES=("${displays[@]}") return 0 } # Installer selector with Select All / Invert support select_installers() { local -a items=("$@") local total_items=${#items[@]} local clear_line=$'\r\033[2K' if [[ $total_items -eq 0 ]]; then return 1 fi # Calculate items per page based on terminal height _get_items_per_page() { local term_height=24 if [[ -t 0 ]] || [[ -t 2 ]]; then term_height=$(stty size < /dev/tty 2> /dev/null | awk '{print $1}') fi if [[ -z "$term_height" || $term_height -le 0 ]]; then if command -v tput > /dev/null 2>&1; then term_height=$(tput lines 2> /dev/null || echo "24") else term_height=24 fi fi local reserved=6 local available=$((term_height - reserved)) if [[ $available -lt 3 ]]; then echo 3 elif [[ $available -gt 50 ]]; then echo 50 else echo "$available" fi } local items_per_page=$(_get_items_per_page) local cursor_pos=0 local top_index=0 # Initialize selection (all unselected by default) local -a selected=() for ((i = 0; i < total_items; i++)); do selected[i]=false done local original_stty="" if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then original_stty=$(stty -g 2> /dev/null || echo "") fi restore_terminal() { trap - EXIT INT TERM if [[ "${IN_ALT_SCREEN:-0}" == "1" ]]; then leave_alt_screen IN_ALT_SCREEN=0 fi show_cursor if [[ -n "${original_stty:-}" ]]; then stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true fi } handle_interrupt() { restore_terminal exit 130 } draw_menu() { items_per_page=$(_get_items_per_page) local max_top_index=0 if [[ $total_items -gt $items_per_page ]]; then max_top_index=$((total_items - items_per_page)) fi if [[ $top_index -gt $max_top_index ]]; then top_index=$max_top_index fi if [[ $top_index -lt 0 ]]; then top_index=0 fi local visible_count=$((total_items - top_index)) [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page if [[ $cursor_pos -gt $((visible_count - 1)) ]]; then cursor_pos=$((visible_count - 1)) fi if [[ $cursor_pos -lt 0 ]]; then cursor_pos=0 fi printf "\033[H" # Calculate selected size and count local selected_size=0 local selected_count=0 for ((i = 0; i < total_items; i++)); do if [[ ${selected[i]} == true ]]; then selected_size=$((selected_size + ${INSTALLER_SIZES[i]:-0})) ((selected_count++)) fi done local selected_human selected_human=$(bytes_to_human "$selected_size") # Show position indicator if scrolling is needed local scroll_indicator="" if [[ $total_items -gt $items_per_page ]]; then local current_pos=$((top_index + cursor_pos + 1)) scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}" fi printf "${PURPLE_BOLD}Select Installers to Remove${NC}%s ${GRAY}, ${selected_human}, ${selected_count} selected${NC}\n" "$scroll_indicator" printf "%s\n" "$clear_line" # Calculate visible range local end_index=$((top_index + visible_count)) # Draw only visible items for ((i = top_index; i < end_index; i++)); do local checkbox="$ICON_EMPTY" [[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID" local rel_pos=$((i - top_index)) if [[ $rel_pos -eq $cursor_pos ]]; then printf "%s${CYAN}${ICON_ARROW} %s %s${NC}\n" "$clear_line" "$checkbox" "${items[i]}" else printf "%s %s %s\n" "$clear_line" "$checkbox" "${items[i]}" fi done # Fill empty slots local items_shown=$visible_count for ((i = items_shown; i < items_per_page; i++)); do printf "%s\n" "$clear_line" done printf "%s\n" "$clear_line" printf "%s${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space Select | Enter Confirm | A All | I Invert | Q Quit${NC}\n" "$clear_line" } trap restore_terminal EXIT trap handle_interrupt INT TERM stty -echo -icanon intr ^C 2> /dev/null || true hide_cursor if [[ -t 1 ]]; then printf "\033[2J\033[H" >&2 fi # Main loop while true; do draw_menu if ! IFS= read -r -s -n1 key; then restore_terminal return 1 fi case "$key" in $'\x1b') IFS= read -r -s -n1 -t 1 key2 || key2="" if [[ "$key2" == "[" ]]; then IFS= read -r -s -n1 -t 1 key3 || key3="" case "$key3" in A) # Up arrow if [[ $cursor_pos -gt 0 ]]; then ((cursor_pos--)) elif [[ $top_index -gt 0 ]]; then ((top_index--)) fi ;; B) # Down arrow local absolute_index=$((top_index + cursor_pos)) local last_index=$((total_items - 1)) if [[ $absolute_index -lt $last_index ]]; then 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++)) elif [[ $((top_index + visible_count)) -lt $total_items ]]; then ((top_index++)) fi fi ;; esac else # ESC alone restore_terminal return 1 fi ;; " ") # Space - toggle current item local idx=$((top_index + cursor_pos)) if [[ ${selected[idx]} == true ]]; then selected[idx]=false else selected[idx]=true fi ;; "a" | "A") # Select all for ((i = 0; i < total_items; i++)); do selected[i]=true done ;; "i" | "I") # Invert selection for ((i = 0; i < total_items; i++)); do if [[ ${selected[i]} == true ]]; then selected[i]=false else selected[i]=true fi done ;; "q" | "Q" | $'\x03') # Quit or Ctrl-C restore_terminal return 1 ;; "" | $'\n' | $'\r') # Enter - confirm MOLE_SELECTION_RESULT="" for ((i = 0; i < total_items; i++)); do if [[ ${selected[i]} == true ]]; then [[ -n "$MOLE_SELECTION_RESULT" ]] && MOLE_SELECTION_RESULT+="," MOLE_SELECTION_RESULT+="$i" fi done restore_terminal return 0 ;; esac done } # Show menu for user selection show_installer_menu() { if [[ ${#DISPLAY_NAMES[@]} -eq 0 ]]; then return 1 fi echo "" MOLE_SELECTION_RESULT="" if ! select_installers "${DISPLAY_NAMES[@]}"; then return 1 fi return 0 } reset_installer_delete_results() { total_deleted=0 total_size_freed_kb=0 total_delete_failed=0 INSTALLER_DELETE_FAILURES=() } reset_installer_delete_plan() { INSTALLER_DELETE_PATHS=() INSTALLER_DELETE_SIZES=() INSTALLER_DELETE_IDENTITIES=() } record_installer_delete_failure() { local file_path="$1" local reason="$2" INSTALLER_DELETE_FAILURES+=("$file_path ($reason)") total_delete_failed=$((total_delete_failed + 1)) } installer_file_size_bytes() { local file_path="$1" local file_size duration duration=$(_mole_timeout_with_deadline "$MOLE_TIMEOUT_QUICK_DETECT_SEC" "${2:-}") || return $? file_size=$(run_with_timeout "$duration" "$STAT_BSD" -f%z "$file_path" < /dev/null 2> /dev/null) || return $? [[ "$file_size" =~ ^[0-9]+$ ]] || return 1 printf '%s\n' "$file_size" } build_installer_delete_plan() { reset_installer_delete_plan local deadline=$((SECONDS + 10#${MOLE_TIMEOUT_DISK_VERIFY_SEC%%.*})) local idx for idx in "$@"; do if [[ ! "$idx" =~ ^[0-9]+$ ]] || [[ $idx -ge ${#INSTALLER_PATHS[@]} ]]; then record_installer_delete_failure "$idx" "stale selection" continue fi local file_path="${INSTALLER_PATHS[$idx]}" local file_size="${INSTALLER_SIZES[$idx]:-0}" if [[ ! "$file_size" =~ ^[0-9]+$ ]]; then file_size=0 fi local identity identity_rc=0 identity=$(mole_deletion_identity "$file_path" "$deadline") || identity_rc=$? if [[ $identity_rc -ne 0 ]]; then record_installer_delete_failure "$file_path" "identity unavailable" if mole_rc_timeout_or_signal "$identity_rc"; then reset_installer_delete_plan return "$identity_rc" fi continue fi INSTALLER_DELETE_PATHS+=("$file_path") INSTALLER_DELETE_SIZES+=("$file_size") INSTALLER_DELETE_IDENTITIES+=("$identity") done [[ ${#INSTALLER_DELETE_PATHS[@]} -gt 0 ]] } execute_installer_delete_plan() { local plan_index for ((plan_index = 0; plan_index < ${#INSTALLER_DELETE_PATHS[@]}; plan_index++)); do local file_path="${INSTALLER_DELETE_PATHS[$plan_index]}" local planned_size="${INSTALLER_DELETE_SIZES[$plan_index]}" local planned_identity="${INSTALLER_DELETE_IDENTITIES[$plan_index]}" if [[ ! -e "$file_path" && ! -L "$file_path" ]]; then record_installer_delete_failure "$file_path" "missing" continue fi local current_identity identity_rc=0 current_identity=$(mole_deletion_identity "$file_path") || identity_rc=$? if [[ $identity_rc -ne 0 ]]; then record_installer_delete_failure "$file_path" "identity unavailable" mole_rc_timeout_or_signal "$identity_rc" && return "$identity_rc" continue fi if [[ "$current_identity" != "$planned_identity" ]]; then record_installer_delete_failure "$file_path" "changed since scan" continue fi local current_size size_rc=0 current_size=$(installer_file_size_bytes "$file_path") || size_rc=$? if [[ $size_rc -ne 0 ]]; then record_installer_delete_failure "$file_path" "size unavailable" if mole_rc_timeout_or_signal "$size_rc" && ! mole_rc_timeout "$size_rc"; then return "$size_rc" fi continue fi if [[ "$current_size" != "$planned_size" ]]; then record_installer_delete_failure "$file_path" "changed since scan" continue fi if mole_delete "$file_path" false "$planned_identity"; then if [[ "${MOLE_DRY_RUN:-0}" == "1" ]] || [[ ! -e "$file_path" && ! -L "$file_path" ]]; then total_size_freed_kb=$((total_size_freed_kb + ((current_size + 1023) / 1024))) total_deleted=$((total_deleted + 1)) else record_installer_delete_failure "$file_path" "still exists" fi else local delete_rc=$? record_installer_delete_failure "$file_path" "delete failed" if mole_rc_timeout_or_signal "$delete_rc"; then return "$delete_rc" fi fi done if [[ $total_delete_failed -gt 0 ]]; then return "$INSTALLER_EXIT_INCOMPLETE" fi return 0 } # Delete selected installers delete_selected_installers() { reset_installer_delete_results # Parse selection indices local -a selected_indices=() if [[ -n "$MOLE_SELECTION_RESULT" ]]; then IFS=',' read -ra selected_indices <<< "$MOLE_SELECTION_RESULT" fi if [[ ${#selected_indices[@]} -eq 0 ]]; then return 1 fi local plan_status=0 build_installer_delete_plan "${selected_indices[@]}" || plan_status=$? if [[ $plan_status -ne 0 ]]; then mole_rc_timeout_or_signal "$plan_status" && return "$plan_status" if [[ $total_delete_failed -gt 0 ]]; then return "$INSTALLER_EXIT_INCOMPLETE" fi return 1 fi local confirm_size=0 local plan_index for ((plan_index = 0; plan_index < ${#INSTALLER_DELETE_SIZES[@]}; plan_index++)); do confirm_size=$((confirm_size + ${INSTALLER_DELETE_SIZES[$plan_index]})) done local confirm_human confirm_human=$(bytes_to_human "$confirm_size") # Show files to be deleted echo -e "${PURPLE_BOLD}Files to be removed:${NC}" for ((plan_index = 0; plan_index < ${#INSTALLER_DELETE_PATHS[@]}; plan_index++)); do local file_path="${INSTALLER_DELETE_PATHS[$plan_index]}" local file_size="${INSTALLER_DELETE_SIZES[$plan_index]}" local size_human size_human=$(bytes_to_human "$file_size") printf ' %s%s%s %s %s, %s%s\n' "$GREEN" "$ICON_SUCCESS" "$NC" \ "$(installer_display_text "${file_path##*/}")" "$GRAY" "$size_human" "$NC" done # Confirm deletion echo "" echo -ne "${PURPLE}${ICON_ARROW}${NC} Delete ${#INSTALLER_DELETE_PATHS[@]} installers, ${confirm_human} ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: " if ! IFS= read -r -s -n1 confirm; then printf '\n' return 1 fi case "$confirm" in $'\e' | q | Q) return 1 ;; "" | $'\n' | $'\r') printf "\r\033[K" # Clear prompt line echo "" # Single line break ;; *) return 1 ;; esac # Delete each selected installer with spinner if [[ -t 1 ]]; then start_inline_spinner "Removing installers..." fi local delete_status=0 execute_installer_delete_plan || delete_status=$? if [[ -t 1 ]]; then stop_inline_spinner fi return "$delete_status" } # Perform the installers cleanup perform_installers() { # Enter alt screen for scanning and selection if [[ -t 1 ]]; then enter_alt_screen IN_ALT_SCREEN=1 printf "\033[2J\033[H" >&2 fi # Collect installers local collect_status=0 collect_installers || collect_status=$? if [[ $collect_status -ne 0 ]]; then if [[ -t 1 ]]; then leave_alt_screen IN_ALT_SCREEN=0 fi printf '\n' if [[ $collect_status -ne 1 ]]; then local scan_reason="incomplete" if mole_rc_timeout "$collect_status"; then scan_reason="timed out" elif mole_rc_timeout_or_signal "$collect_status"; then scan_reason="interrupted" fi printf '%sInstaller scan %s%s; no files selected' "$YELLOW" "$scan_reason" "$NC" >&2 if [[ -n "$INSTALLER_SCAN_FAILURE_PATH" ]]; then printf ' (%s)' "$(installer_display_text "$INSTALLER_SCAN_FAILURE_PATH")" >&2 fi printf '\nCheck that the scan locations are readable and responsive, then retry mo installer.\n' >&2 return "$collect_status" fi echo -e "${GREEN}${ICON_SUCCESS}${NC} No installer files to clean" printf '\n' return 2 # Nothing to clean fi # Show menu if ! show_installer_menu; then if [[ -t 1 ]]; then leave_alt_screen IN_ALT_SCREEN=0 fi return 1 # User cancelled fi # Leave alt screen before deletion (so confirmation and results are on main screen) if [[ -t 1 ]]; then leave_alt_screen IN_ALT_SCREEN=0 fi # Delete selected local delete_status=0 delete_selected_installers || delete_status=$? if [[ $delete_status -ne 0 ]]; then return "$delete_status" fi return 0 } show_summary() { local summary_heading="Installers cleaned" local -a summary_details=() local dry_run_mode="${MOLE_DRY_RUN:-0}" if [[ "$dry_run_mode" == "1" ]]; then summary_heading="Dry run complete - no changes made" elif [[ $total_delete_failed -gt 0 ]]; then summary_heading="Installer cleanup incomplete" fi if [[ $total_deleted -gt 0 ]]; then local freed_mb freed_mb=$(echo "$total_size_freed_kb" | awk '{printf "%.2f", $1/1024}') if [[ "$dry_run_mode" == "1" ]]; then summary_details+=("Would remove ${GREEN}$total_deleted${NC} installers, free ${GREEN}${freed_mb}MB${NC}") else summary_details+=("Removed ${GREEN}$total_deleted${NC} installers, freed ${GREEN}${freed_mb}MB${NC}") if [[ $total_delete_failed -eq 0 ]]; then summary_details+=("Your Mac is cleaner now!") fi fi else summary_details+=("No installers were removed") fi if [[ $total_delete_failed -gt 0 ]]; then local failure_label="installers" [[ $total_delete_failed -eq 1 ]] && failure_label="installer" summary_details+=("Failed to remove ${YELLOW}$total_delete_failed${NC} $failure_label") local failure_count=${#INSTALLER_DELETE_FAILURES[@]} local failure_limit=5 if [[ $failure_count -lt $failure_limit ]]; then failure_limit=$failure_count fi local failure_index for ((failure_index = 0; failure_index < failure_limit; failure_index++)); do local failure_detail failure_detail=$(installer_display_text "${INSTALLER_DELETE_FAILURES[$failure_index]}") # The shared summary renderer interprets escapes for colors. failure_detail=${failure_detail//\\/\\\\} summary_details+=("${ICON_WARNING} $failure_detail") done if [[ $failure_count -gt $failure_limit ]]; then summary_details+=("${ICON_WARNING} $((failure_count - failure_limit)) more failed") fi fi print_summary_block "$summary_heading" "${summary_details[@]}" printf '\n' } main() { for arg in "$@"; do case "$arg" in "--help" | "-h") show_installer_help exit 0 ;; "--debug") export MO_DEBUG=1 ;; "--dry-run" | "-n") export MOLE_DRY_RUN=1 ;; *) echo "Unknown option: $arg" >&2 exit 1 ;; esac done if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No installer files will be removed" printf '\n' fi hide_cursor # Capture the status without tripping errexit: a bare call under set -e # would exit the script before the case handler below could report # incomplete cleanup. local exit_code=0 perform_installers || exit_code=$? show_cursor case $exit_code in 0) show_summary ;; "$INSTALLER_EXIT_INCOMPLETE") show_summary return 1 ;; "$INSTALLER_EXIT_SCAN_FAILED") return 1 ;; 1) printf '\n' ;; 2) # Already handled by collect_installers ;; *) # A timeout or signal in the delete phase stops before later # selections, but it has already recorded which file it hit. # Report that and what was removed instead of ending silently. if [[ $total_delete_failed -gt 0 ]]; then show_summary if mole_rc_timeout "$exit_code"; then echo -e "${YELLOW}${ICON_WARNING}${NC} A file check timed out, so later selections were left in place. Retry mo installer." fi fi return "$exit_code" ;; esac return 0 } # Only run main if not in test mode if [[ "${MOLE_TEST_MODE:-0}" != "1" ]]; then main "$@" fi