mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 20:54:50 +00:00
Previously, apps matching DATA_PROTECTED_BUNDLES patterns (VPNs, dev tools, etc.) could not be uninstalled because should_protect_path blocked their deletion. Now use MOLE_UNINSTALL_MODE to distinguish between cleanup and explicit uninstall, allowing users to remove these apps when they choose to while still protecting their data during normal cleanup operations. Also allow deletion of installer receipts in /private/var/db/receipts/.
654 lines
24 KiB
Bash
Executable File
654 lines
24 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
set -euo pipefail
|
|
|
|
# Ensure common.sh is loaded.
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
[[ -z "${MOLE_COMMON_LOADED:-}" ]] && source "$SCRIPT_DIR/lib/core/common.sh"
|
|
|
|
# Load Homebrew cask support (provides get_brew_cask_name, brew_uninstall_cask)
|
|
[[ -f "$SCRIPT_DIR/lib/uninstall/brew.sh" ]] && source "$SCRIPT_DIR/lib/uninstall/brew.sh"
|
|
|
|
# Batch uninstall with a single confirmation.
|
|
|
|
# High-performance sensitive data detection (pure Bash, no subprocess)
|
|
# Faster than grep for batch operations, especially when processing many apps
|
|
has_sensitive_data() {
|
|
local files="$1"
|
|
[[ -z "$files" ]] && return 1
|
|
|
|
while IFS= read -r file; do
|
|
[[ -z "$file" ]] && continue
|
|
|
|
# Use Bash native pattern matching (faster than spawning grep)
|
|
case "$file" in
|
|
*/.warp* | */.config/* | */themes/* | */settings/* | */User\ Data/* | \
|
|
*/.ssh/* | */.gnupg/* | */Documents/* | */Preferences/*.plist | \
|
|
*/Desktop/* | */Downloads/* | */Movies/* | */Music/* | */Pictures/* | \
|
|
*/.password* | */.token* | */.auth* | */keychain* | \
|
|
*/Passwords/* | */Accounts/* | */Cookies/* | \
|
|
*/.aws/* | */.docker/config.json | */.kube/* | \
|
|
*/credentials/* | */secrets/*)
|
|
return 0 # Found sensitive data
|
|
;;
|
|
esac
|
|
done <<< "$files"
|
|
|
|
return 1 # Not found
|
|
}
|
|
|
|
# Decode and validate base64 file list (safe for set -e).
|
|
decode_file_list() {
|
|
local encoded="$1"
|
|
local app_name="$2"
|
|
local decoded
|
|
|
|
# macOS uses -D, GNU uses -d. Always return 0 for set -e safety.
|
|
if ! decoded=$(printf '%s' "$encoded" | base64 -D 2> /dev/null); then
|
|
if ! decoded=$(printf '%s' "$encoded" | base64 -d 2> /dev/null); then
|
|
log_error "Failed to decode file list for $app_name" >&2
|
|
echo ""
|
|
return 0 # Return success with empty string
|
|
fi
|
|
fi
|
|
|
|
if [[ "$decoded" =~ $'\0' ]]; then
|
|
log_warning "File list for $app_name contains null bytes, rejecting" >&2
|
|
echo ""
|
|
return 0 # Return success with empty string
|
|
fi
|
|
|
|
while IFS= read -r line; do
|
|
if [[ -n "$line" && ! "$line" =~ ^/ ]]; then
|
|
log_warning "Invalid path in file list for $app_name: $line" >&2
|
|
echo ""
|
|
return 0 # Return success with empty string
|
|
fi
|
|
done <<< "$decoded"
|
|
|
|
echo "$decoded"
|
|
return 0
|
|
}
|
|
# Note: find_app_files() and calculate_total_size() are in lib/core/common.sh.
|
|
|
|
# Stop Launch Agents/Daemons for an app.
|
|
stop_launch_services() {
|
|
local bundle_id="$1"
|
|
local has_system_files="${2:-false}"
|
|
|
|
[[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0
|
|
|
|
if [[ -d ~/Library/LaunchAgents ]]; then
|
|
while IFS= read -r -d '' plist; do
|
|
launchctl unload "$plist" 2> /dev/null || true
|
|
done < <(find ~/Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null)
|
|
fi
|
|
|
|
if [[ "$has_system_files" == "true" ]]; then
|
|
if [[ -d /Library/LaunchAgents ]]; then
|
|
while IFS= read -r -d '' plist; do
|
|
sudo launchctl unload "$plist" 2> /dev/null || true
|
|
done < <(find /Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null)
|
|
fi
|
|
if [[ -d /Library/LaunchDaemons ]]; then
|
|
while IFS= read -r -d '' plist; do
|
|
sudo launchctl unload "$plist" 2> /dev/null || true
|
|
done < <(find /Library/LaunchDaemons -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null)
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Remove macOS Login Items for an app
|
|
remove_login_item() {
|
|
local app_name="$1"
|
|
local bundle_id="$2"
|
|
|
|
# Skip if no identifiers provided
|
|
[[ -z "$app_name" && -z "$bundle_id" ]] && return 0
|
|
|
|
# Strip .app suffix if present (login items don't include it)
|
|
local clean_name="${app_name%.app}"
|
|
|
|
# Remove from Login Items using index-based deletion (handles broken items)
|
|
if [[ -n "$clean_name" ]]; then
|
|
# Escape double quotes and backslashes for AppleScript
|
|
local escaped_name="${clean_name//\\/\\\\}"
|
|
escaped_name="${escaped_name//\"/\\\"}"
|
|
|
|
osascript <<- EOF > /dev/null 2>&1 || true
|
|
tell application "System Events"
|
|
try
|
|
set itemCount to count of login items
|
|
-- Delete in reverse order to avoid index shifting
|
|
repeat with i from itemCount to 1 by -1
|
|
try
|
|
set itemName to name of login item i
|
|
if itemName is "$escaped_name" then
|
|
delete login item i
|
|
end if
|
|
end try
|
|
end repeat
|
|
end try
|
|
end tell
|
|
EOF
|
|
fi
|
|
}
|
|
|
|
# Remove files (handles symlinks, optional sudo).
|
|
remove_file_list() {
|
|
local file_list="$1"
|
|
local use_sudo="${2:-false}"
|
|
local count=0
|
|
|
|
while IFS= read -r file; do
|
|
[[ -n "$file" && -e "$file" ]] || continue
|
|
|
|
if ! validate_path_for_deletion "$file"; then
|
|
continue
|
|
fi
|
|
|
|
if [[ -L "$file" ]]; then
|
|
if [[ "$use_sudo" == "true" ]]; then
|
|
sudo rm "$file" 2> /dev/null && ((++count)) || true
|
|
else
|
|
rm "$file" 2> /dev/null && ((++count)) || true
|
|
fi
|
|
else
|
|
if [[ "$use_sudo" == "true" ]]; then
|
|
safe_sudo_remove "$file" && ((++count)) || true
|
|
else
|
|
safe_remove "$file" true && ((++count)) || true
|
|
fi
|
|
fi
|
|
done <<< "$file_list"
|
|
|
|
echo "$count"
|
|
}
|
|
|
|
# Batch uninstall with single confirmation.
|
|
batch_uninstall_applications() {
|
|
local total_size_freed=0
|
|
|
|
# Trap to clean up spinner and uninstall mode on interrupt
|
|
trap 'stop_inline_spinner 2>/dev/null; unset MOLE_UNINSTALL_MODE; echo ""; return 130' INT TERM
|
|
|
|
# shellcheck disable=SC2154
|
|
if [[ ${#selected_apps[@]} -eq 0 ]]; then
|
|
log_warning "No applications selected for uninstallation"
|
|
return 0
|
|
fi
|
|
|
|
# Pre-scan: running apps, sudo needs, size.
|
|
local -a running_apps=()
|
|
local -a sudo_apps=()
|
|
local total_estimated_size=0
|
|
local -a app_details=()
|
|
|
|
# Cache current user outside loop
|
|
local current_user=$(whoami)
|
|
|
|
if [[ -t 1 ]]; then start_inline_spinner "Scanning files..."; fi
|
|
for selected_app in "${selected_apps[@]}"; do
|
|
[[ -z "$selected_app" ]] && continue
|
|
IFS='|' read -r _ app_path app_name bundle_id _ _ <<< "$selected_app"
|
|
|
|
# Check running app by bundle executable if available
|
|
local exec_name=""
|
|
local info_plist="$app_path/Contents/Info.plist"
|
|
if [[ -e "$info_plist" ]]; then
|
|
exec_name=$(defaults read "$info_plist" CFBundleExecutable 2> /dev/null || echo "")
|
|
fi
|
|
if pgrep -qx "${exec_name:-$app_name}" 2> /dev/null; then
|
|
running_apps+=("$app_name")
|
|
fi
|
|
|
|
local cask_name="" is_brew_cask="false"
|
|
local resolved_path=$(readlink "$app_path" 2> /dev/null || echo "")
|
|
if [[ "$resolved_path" == */Caskroom/* ]]; then
|
|
# Extract cask name using bash parameter expansion (faster than sed)
|
|
local tmp="${resolved_path#*/Caskroom/}"
|
|
cask_name="${tmp%%/*}"
|
|
[[ -n "$cask_name" ]] && is_brew_cask="true"
|
|
elif command -v get_brew_cask_name > /dev/null 2>&1; then
|
|
local detected_cask
|
|
detected_cask=$(get_brew_cask_name "$app_path" 2> /dev/null || true)
|
|
if [[ -n "$detected_cask" ]]; then
|
|
cask_name="$detected_cask"
|
|
is_brew_cask="true"
|
|
fi
|
|
fi
|
|
|
|
# Check if sudo is needed
|
|
local needs_sudo=false
|
|
local app_owner=$(get_file_owner "$app_path")
|
|
if [[ ! -w "$(dirname "$app_path")" ]] ||
|
|
[[ "$app_owner" == "root" ]] ||
|
|
[[ -n "$app_owner" && "$app_owner" != "$current_user" ]]; then
|
|
needs_sudo=true
|
|
fi
|
|
|
|
# Size estimate includes related and system files.
|
|
local app_size_kb=$(get_path_size_kb "$app_path")
|
|
local related_files=$(find_app_files "$bundle_id" "$app_name")
|
|
local related_size_kb=$(calculate_total_size "$related_files")
|
|
# system_files is a newline-separated string, not an array.
|
|
# shellcheck disable=SC2178,SC2128
|
|
local system_files=$(find_app_system_files "$bundle_id" "$app_name")
|
|
# shellcheck disable=SC2128
|
|
local system_size_kb=$(calculate_total_size "$system_files")
|
|
local total_kb=$((app_size_kb + related_size_kb + system_size_kb))
|
|
((total_estimated_size += total_kb))
|
|
|
|
# shellcheck disable=SC2128
|
|
if [[ -n "$system_files" ]]; then
|
|
needs_sudo=true
|
|
fi
|
|
|
|
if [[ "$needs_sudo" == "true" ]]; then
|
|
sudo_apps+=("$app_name")
|
|
fi
|
|
|
|
# Check for sensitive user data once.
|
|
local has_sensitive_data="false"
|
|
if has_sensitive_data "$related_files"; then
|
|
has_sensitive_data="true"
|
|
fi
|
|
|
|
# Store details for later use (base64 keeps lists on one line).
|
|
local encoded_files
|
|
encoded_files=$(printf '%s' "$related_files" | base64 | tr -d '\n')
|
|
local encoded_system_files
|
|
encoded_system_files=$(printf '%s' "$system_files" | base64 | tr -d '\n')
|
|
app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$encoded_files|$encoded_system_files|$has_sensitive_data|$needs_sudo|$is_brew_cask|$cask_name")
|
|
done
|
|
if [[ -t 1 ]]; then stop_inline_spinner; fi
|
|
|
|
local size_display=$(bytes_to_human "$((total_estimated_size * 1024))")
|
|
|
|
echo ""
|
|
echo -e "${PURPLE_BOLD}Files to be removed:${NC}"
|
|
echo ""
|
|
|
|
# Warn if user data is detected.
|
|
local has_user_data=false
|
|
for detail in "${app_details[@]}"; do
|
|
IFS='|' read -r _ _ _ _ _ _ has_sensitive_data <<< "$detail"
|
|
if [[ "$has_sensitive_data" == "true" ]]; then
|
|
has_user_data=true
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [[ "$has_user_data" == "true" ]]; then
|
|
echo -e "${YELLOW}${ICON_WARNING}${NC} ${YELLOW}Note: Some apps contain user configurations/themes${NC}"
|
|
echo ""
|
|
fi
|
|
|
|
for detail in "${app_details[@]}"; do
|
|
IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag is_brew_cask cask_name <<< "$detail"
|
|
local app_size_display=$(bytes_to_human "$((total_kb * 1024))")
|
|
|
|
local brew_tag=""
|
|
[[ "$is_brew_cask" == "true" ]] && brew_tag=" ${CYAN}[Brew]${NC}"
|
|
echo -e "${BLUE}${ICON_CONFIRM}${NC} ${app_name}${brew_tag} ${GRAY}(${app_size_display})${NC}"
|
|
|
|
# Show detailed file list for ALL apps (brew casks leave user data behind)
|
|
local related_files=$(decode_file_list "$encoded_files" "$app_name")
|
|
local system_files=$(decode_file_list "$encoded_system_files" "$app_name")
|
|
|
|
echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${app_path/$HOME/~}"
|
|
|
|
# Show related files (limit to 5).
|
|
local file_count=0
|
|
local max_files=5
|
|
while IFS= read -r file; do
|
|
if [[ -n "$file" && -e "$file" ]]; then
|
|
if [[ $file_count -lt $max_files ]]; then
|
|
echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${file/$HOME/~}"
|
|
fi
|
|
((file_count++))
|
|
fi
|
|
done <<< "$related_files"
|
|
|
|
# Show system files (limit to 5).
|
|
local sys_file_count=0
|
|
while IFS= read -r file; do
|
|
if [[ -n "$file" && -e "$file" ]]; then
|
|
if [[ $sys_file_count -lt $max_files ]]; then
|
|
echo -e " ${BLUE}${ICON_SOLID}${NC} System: $file"
|
|
fi
|
|
((sys_file_count++))
|
|
fi
|
|
done <<< "$system_files"
|
|
|
|
local total_hidden=$((file_count > max_files ? file_count - max_files : 0))
|
|
((total_hidden += sys_file_count > max_files ? sys_file_count - max_files : 0))
|
|
if [[ $total_hidden -gt 0 ]]; then
|
|
echo -e " ${GRAY} ... and ${total_hidden} more files${NC}"
|
|
fi
|
|
done
|
|
|
|
# Confirmation before requesting sudo.
|
|
local app_total=${#selected_apps[@]}
|
|
local app_text="app"
|
|
[[ $app_total -gt 1 ]] && app_text="apps"
|
|
|
|
echo ""
|
|
local removal_note="Remove ${app_total} ${app_text}"
|
|
[[ -n "$size_display" ]] && removal_note+=" (${size_display})"
|
|
if [[ ${#running_apps[@]} -gt 0 ]]; then
|
|
removal_note+=" ${YELLOW}[Running]${NC}"
|
|
fi
|
|
echo -ne "${PURPLE}${ICON_ARROW}${NC} ${removal_note} ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: "
|
|
|
|
drain_pending_input # Clean up any pending input before confirmation
|
|
IFS= read -r -s -n1 key || key=""
|
|
drain_pending_input # Clean up any escape sequence remnants
|
|
case "$key" in
|
|
$'\e' | q | Q)
|
|
echo ""
|
|
echo ""
|
|
return 0
|
|
;;
|
|
"" | $'\n' | $'\r' | y | Y)
|
|
echo "" # Move to next line
|
|
;;
|
|
*)
|
|
echo ""
|
|
echo ""
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
# Enable uninstall mode - allows deletion of data-protected apps (VPNs, dev tools, etc.)
|
|
# that user explicitly chose to uninstall. System-critical components remain protected.
|
|
export MOLE_UNINSTALL_MODE=1
|
|
|
|
# Request sudo if needed.
|
|
if [[ ${#sudo_apps[@]} -gt 0 ]]; then
|
|
if ! sudo -n true 2> /dev/null; then
|
|
if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then
|
|
echo ""
|
|
log_error "Admin access denied"
|
|
return 1
|
|
fi
|
|
fi
|
|
# Keep sudo alive during uninstall.
|
|
parent_pid=$$
|
|
(while true; do
|
|
if ! kill -0 "$parent_pid" 2> /dev/null; then
|
|
exit 0
|
|
fi
|
|
sudo -n true
|
|
sleep 60
|
|
done 2> /dev/null) &
|
|
sudo_keepalive_pid=$!
|
|
fi
|
|
|
|
# Perform uninstallations with per-app progress feedback
|
|
local success_count=0 failed_count=0
|
|
local brew_apps_removed=0 # Track successful brew uninstalls for autoremove tip
|
|
local -a failed_items=()
|
|
local -a success_items=()
|
|
local current_index=0
|
|
for detail in "${app_details[@]}"; do
|
|
((current_index++))
|
|
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 <<< "$detail"
|
|
local related_files=$(decode_file_list "$encoded_files" "$app_name")
|
|
local system_files=$(decode_file_list "$encoded_system_files" "$app_name")
|
|
local reason=""
|
|
|
|
# Show progress for current app
|
|
local brew_tag=""
|
|
[[ "$is_brew_cask" == "true" ]] && brew_tag=" ${CYAN}[Brew]${NC}"
|
|
if [[ -t 1 ]]; then
|
|
if [[ ${#app_details[@]} -gt 1 ]]; then
|
|
start_inline_spinner "[$current_index/${#app_details[@]}] Uninstalling ${app_name}${brew_tag}..."
|
|
else
|
|
start_inline_spinner "Uninstalling ${app_name}${brew_tag}..."
|
|
fi
|
|
fi
|
|
|
|
# Stop Launch Agents/Daemons before removal.
|
|
local has_system_files="false"
|
|
[[ -n "$system_files" ]] && has_system_files="true"
|
|
|
|
stop_launch_services "$bundle_id" "$has_system_files"
|
|
|
|
# Remove from Login Items
|
|
remove_login_item "$app_name" "$bundle_id"
|
|
|
|
if ! force_kill_app "$app_name" "$app_path"; then
|
|
reason="still running"
|
|
fi
|
|
|
|
# Remove the application only if not running.
|
|
# Stop spinner before any removal attempt (avoids mixed output on errors)
|
|
[[ -t 1 ]] && stop_inline_spinner
|
|
|
|
local used_brew_successfully=false
|
|
if [[ -z "$reason" ]]; then
|
|
if [[ "$is_brew_cask" == "true" && -n "$cask_name" ]]; then
|
|
# Use brew_uninstall_cask helper (handles env vars, timeout, verification)
|
|
if brew_uninstall_cask "$cask_name" "$app_path"; then
|
|
used_brew_successfully=true
|
|
else
|
|
# Fallback to manual removal if brew fails
|
|
if [[ "$needs_sudo" == true ]]; then
|
|
safe_sudo_remove "$app_path" || reason="remove failed"
|
|
else
|
|
safe_remove "$app_path" true || reason="remove failed"
|
|
fi
|
|
fi
|
|
elif [[ "$needs_sudo" == true ]]; then
|
|
if ! safe_sudo_remove "$app_path"; then
|
|
local app_owner=$(get_file_owner "$app_path")
|
|
if [[ -n "$app_owner" && "$app_owner" != "$current_user" && "$app_owner" != "root" ]]; then
|
|
reason="owned by $app_owner"
|
|
else
|
|
reason="permission denied"
|
|
fi
|
|
fi
|
|
else
|
|
safe_remove "$app_path" true || reason="remove failed"
|
|
fi
|
|
fi
|
|
|
|
# Remove related files if app removal succeeded.
|
|
if [[ -z "$reason" ]]; then
|
|
remove_file_list "$related_files" "false" > /dev/null
|
|
|
|
# If brew successfully uninstalled the cask, avoid deleting
|
|
# system-level files Mole discovered. Brew manages its own
|
|
# receipts/symlinks and we don't want to fight it.
|
|
if [[ "$used_brew_successfully" != "true" ]]; then
|
|
remove_file_list "$system_files" "true" > /dev/null
|
|
fi
|
|
|
|
# Clean up macOS defaults (preference domains).
|
|
if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then
|
|
if defaults read "$bundle_id" &> /dev/null; then
|
|
defaults delete "$bundle_id" 2> /dev/null || true
|
|
fi
|
|
|
|
# ByHost preferences (machine-specific).
|
|
if [[ -d ~/Library/Preferences/ByHost ]]; then
|
|
find ~/Library/Preferences/ByHost -maxdepth 1 -name "${bundle_id}.*.plist" -delete 2> /dev/null || true
|
|
fi
|
|
fi
|
|
|
|
# Show success
|
|
if [[ -t 1 ]]; then
|
|
if [[ ${#app_details[@]} -gt 1 ]]; then
|
|
echo -e "${GREEN}✓${NC} [$current_index/${#app_details[@]}] ${app_name}"
|
|
else
|
|
echo -e "${GREEN}✓${NC} ${app_name}"
|
|
fi
|
|
fi
|
|
|
|
((total_size_freed += total_kb))
|
|
((success_count++))
|
|
[[ "$used_brew_successfully" == "true" ]] && ((brew_apps_removed++))
|
|
((files_cleaned++))
|
|
((total_items++))
|
|
success_items+=("$app_name")
|
|
else
|
|
# Show failure
|
|
if [[ -t 1 ]]; then
|
|
if [[ ${#app_details[@]} -gt 1 ]]; then
|
|
echo -e "${ICON_ERROR} [$current_index/${#app_details[@]}] ${app_name} ${GRAY}($reason)${NC}"
|
|
else
|
|
echo -e "${ICON_ERROR} ${app_name} failed: $reason"
|
|
fi
|
|
fi
|
|
|
|
((failed_count++))
|
|
failed_items+=("$app_name:$reason")
|
|
fi
|
|
done
|
|
|
|
# Summary
|
|
local freed_display
|
|
freed_display=$(bytes_to_human "$((total_size_freed * 1024))")
|
|
|
|
local summary_status="success"
|
|
local -a summary_details=()
|
|
|
|
if [[ $success_count -gt 0 ]]; then
|
|
local success_list="${success_items[*]}"
|
|
local success_text="app"
|
|
[[ $success_count -gt 1 ]] && success_text="apps"
|
|
local success_line="Removed ${success_count} ${success_text}"
|
|
if [[ -n "$freed_display" ]]; then
|
|
success_line+=", freed ${GREEN}${freed_display}${NC}"
|
|
fi
|
|
|
|
# Format app list with max 3 per line.
|
|
if [[ -n "$success_list" ]]; then
|
|
local idx=0
|
|
local is_first_line=true
|
|
local current_line=""
|
|
|
|
for app_name in "${success_items[@]}"; do
|
|
local display_item="${GREEN}${app_name}${NC}"
|
|
|
|
if ((idx % 3 == 0)); then
|
|
if [[ -n "$current_line" ]]; then
|
|
summary_details+=("$current_line")
|
|
fi
|
|
if [[ "$is_first_line" == true ]]; then
|
|
current_line="${success_line}: $display_item"
|
|
is_first_line=false
|
|
else
|
|
current_line="$display_item"
|
|
fi
|
|
else
|
|
current_line="$current_line, $display_item"
|
|
fi
|
|
((idx++))
|
|
done
|
|
if [[ -n "$current_line" ]]; then
|
|
summary_details+=("$current_line")
|
|
fi
|
|
else
|
|
summary_details+=("$success_line")
|
|
fi
|
|
fi
|
|
|
|
if [[ $failed_count -gt 0 ]]; then
|
|
summary_status="warn"
|
|
|
|
local failed_names=()
|
|
for item in "${failed_items[@]}"; do
|
|
local name=${item%%:*}
|
|
failed_names+=("$name")
|
|
done
|
|
local failed_list="${failed_names[*]}"
|
|
|
|
local reason_summary="could not be removed"
|
|
if [[ $failed_count -eq 1 ]]; then
|
|
local first_reason=${failed_items[0]#*:}
|
|
case "$first_reason" in
|
|
still*running*) reason_summary="is still running" ;;
|
|
remove*failed*) reason_summary="could not be removed" ;;
|
|
permission*denied*) reason_summary="permission denied" ;;
|
|
owned*by*) reason_summary="$first_reason (try with sudo)" ;;
|
|
*) reason_summary="$first_reason" ;;
|
|
esac
|
|
fi
|
|
summary_details+=("Failed: ${RED}${failed_list}${NC} ${reason_summary}")
|
|
fi
|
|
|
|
if [[ $success_count -eq 0 && $failed_count -eq 0 ]]; then
|
|
summary_status="info"
|
|
summary_details+=("No applications were uninstalled.")
|
|
fi
|
|
|
|
local title="Uninstall complete"
|
|
if [[ "$summary_status" == "warn" ]]; then
|
|
title="Uninstall incomplete"
|
|
fi
|
|
|
|
echo ""
|
|
print_summary_block "$title" "${summary_details[@]}"
|
|
printf '\n'
|
|
|
|
# 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
|
|
|
|
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 [[ $removed_count -gt 0 ]]; then
|
|
echo -e "${GREEN}${ICON_SUCCESS}${NC} Cleaned $removed_count orphaned brew dependencies"
|
|
echo ""
|
|
fi
|
|
fi
|
|
|
|
# Clean up Dock entries for uninstalled apps.
|
|
if [[ $success_count -gt 0 ]]; then
|
|
local -a removed_paths=()
|
|
for detail in "${app_details[@]}"; do
|
|
IFS='|' read -r app_name app_path _ _ _ _ <<< "$detail"
|
|
for success_name in "${success_items[@]}"; do
|
|
if [[ "$success_name" == "$app_name" ]]; then
|
|
removed_paths+=("$app_path")
|
|
break
|
|
fi
|
|
done
|
|
done
|
|
if [[ ${#removed_paths[@]} -gt 0 ]]; then
|
|
remove_apps_from_dock "${removed_paths[@]}" 2> /dev/null || true
|
|
fi
|
|
fi
|
|
|
|
# Clean up sudo keepalive if it was started.
|
|
if [[ -n "${sudo_keepalive_pid:-}" ]]; then
|
|
kill "$sudo_keepalive_pid" 2> /dev/null || true
|
|
wait "$sudo_keepalive_pid" 2> /dev/null || true
|
|
sudo_keepalive_pid=""
|
|
fi
|
|
|
|
# Disable uninstall mode
|
|
unset MOLE_UNINSTALL_MODE
|
|
|
|
# Invalidate cache if any apps were successfully uninstalled.
|
|
if [[ $success_count -gt 0 ]]; then
|
|
local cache_file="$HOME/.cache/mole/app_scan_cache"
|
|
rm -f "$cache_file" 2> /dev/null || true
|
|
fi
|
|
|
|
((total_size_cleaned += total_size_freed))
|
|
unset failed_items
|
|
}
|