mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 15:04:42 +00:00
Merge branch 'dev' into network_graph
This commit is contained in:
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 325 KiB |
@@ -93,9 +93,14 @@ Even with `sudo`, these paths are **unconditionally blocked**:
|
|||||||
/bin, /sbin, /usr # Core binaries
|
/bin, /sbin, /usr # Core binaries
|
||||||
/etc, /var # System configuration
|
/etc, /var # System configuration
|
||||||
/Library/Extensions # Kernel extensions
|
/Library/Extensions # Kernel extensions
|
||||||
|
/private # System-private directories
|
||||||
```
|
```
|
||||||
|
|
||||||
**Exception:** `/System/Library/Caches/com.apple.coresymbolicationd/data` (safe, rebuildable cache).
|
**Exceptions:**
|
||||||
|
|
||||||
|
- `/System/Library/Caches/com.apple.coresymbolicationd/data` (safe, rebuildable cache)
|
||||||
|
- `/private/tmp`, `/private/var/tmp`, `/private/var/log`, `/private/var/folders`
|
||||||
|
- `/private/var/db/diagnostics`, `/private/var/db/DiagnosticPipeline`, `/private/var/db/powerlog`, `/private/var/db/reportmemoryexception`
|
||||||
|
|
||||||
**Code:** `lib/core/file_ops.sh:60-78`
|
**Code:** `lib/core/file_ops.sh:60-78`
|
||||||
|
|
||||||
@@ -161,6 +166,7 @@ For user-selected app removal:
|
|||||||
- **Safety Limit:** 3-char minimum (prevents "Go" matching "Google")
|
- **Safety Limit:** 3-char minimum (prevents "Go" matching "Google")
|
||||||
- **Disabled:** Fuzzy matching and wildcard expansion for short names.
|
- **Disabled:** Fuzzy matching and wildcard expansion for short names.
|
||||||
- **User Confirmation:** Required before deletion.
|
- **User Confirmation:** Required before deletion.
|
||||||
|
- **Receipt Scans:** BOM-derived files are restricted to app-specific prefixes (e.g., `/Applications`, `/Library/Application Support`). Shared directories like `/Library/Frameworks` are **excluded** to prevent collateral damage.
|
||||||
|
|
||||||
**Code:** `lib/clean/apps.sh:uninstall_app()`
|
**Code:** `lib/clean/apps.sh:uninstall_app()`
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ clean_user_essentials() {
|
|||||||
start_section_spinner "Scanning caches..."
|
start_section_spinner "Scanning caches..."
|
||||||
safe_clean ~/Library/Caches/* "User app cache"
|
safe_clean ~/Library/Caches/* "User app cache"
|
||||||
stop_section_spinner
|
stop_section_spinner
|
||||||
start_section_spinner "Scanning empty items..."
|
|
||||||
clean_empty_library_items
|
|
||||||
stop_section_spinner
|
|
||||||
safe_clean ~/Library/Logs/* "User app logs"
|
safe_clean ~/Library/Logs/* "User app logs"
|
||||||
if is_path_whitelisted "$HOME/.Trash"; then
|
if is_path_whitelisted "$HOME/.Trash"; then
|
||||||
note_activity
|
note_activity
|
||||||
@@ -17,66 +15,6 @@ clean_user_essentials() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
clean_empty_library_items() {
|
|
||||||
if [[ ! -d "$HOME/Library" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 1. Clean top-level empty directories and files in Library
|
|
||||||
local -a empty_dirs=()
|
|
||||||
while IFS= read -r -d '' dir; do
|
|
||||||
[[ -d "$dir" ]] && empty_dirs+=("$dir")
|
|
||||||
done < <(find "$HOME/Library" -mindepth 1 -maxdepth 1 -type d -empty -print0 2> /dev/null)
|
|
||||||
|
|
||||||
if [[ ${#empty_dirs[@]} -gt 0 ]]; then
|
|
||||||
safe_clean "${empty_dirs[@]}" "Empty Library folders"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. Clean empty subdirectories in Application Support and other key locations
|
|
||||||
# Iteratively remove empty directories until no more are found
|
|
||||||
local -a key_locations=(
|
|
||||||
"$HOME/Library/Application Support"
|
|
||||||
"$HOME/Library/Caches"
|
|
||||||
)
|
|
||||||
|
|
||||||
for location in "${key_locations[@]}"; do
|
|
||||||
[[ -d "$location" ]] || continue
|
|
||||||
|
|
||||||
# Limit passes to keep cleanup fast; 3 iterations handle most nested scenarios.
|
|
||||||
local max_iterations=3
|
|
||||||
local iteration=0
|
|
||||||
|
|
||||||
while [[ $iteration -lt $max_iterations ]]; do
|
|
||||||
local -a nested_empty_dirs=()
|
|
||||||
# Find empty directories
|
|
||||||
while IFS= read -r -d '' dir; do
|
|
||||||
# Skip if whitelisted
|
|
||||||
if is_path_whitelisted "$dir"; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
# Skip protected system components
|
|
||||||
local dir_name=$(basename "$dir")
|
|
||||||
if is_critical_system_component "$dir_name"; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
[[ -d "$dir" ]] && nested_empty_dirs+=("$dir")
|
|
||||||
done < <(find "$location" -mindepth 1 -type d -empty -print0 2> /dev/null)
|
|
||||||
|
|
||||||
# If no empty dirs found, we're done with this location
|
|
||||||
if [[ ${#nested_empty_dirs[@]} -eq 0 ]]; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
local location_name=$(basename "$location")
|
|
||||||
safe_clean "${nested_empty_dirs[@]}" "Empty $location_name subdirs"
|
|
||||||
|
|
||||||
((iteration++))
|
|
||||||
done
|
|
||||||
done
|
|
||||||
|
|
||||||
# Empty file cleanup is skipped to avoid removing app sentinel files.
|
|
||||||
}
|
|
||||||
|
|
||||||
# Remove old Google Chrome versions while keeping Current.
|
# Remove old Google Chrome versions while keeping Current.
|
||||||
clean_chrome_old_versions() {
|
clean_chrome_old_versions() {
|
||||||
local -a app_paths=(
|
local -a app_paths=(
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ readonly MOLE_APP_PROTECTION_LOADED=1
|
|||||||
_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
[[ -z "${MOLE_BASE_LOADED:-}" ]] && source "$_MOLE_CORE_DIR/base.sh"
|
[[ -z "${MOLE_BASE_LOADED:-}" ]] && source "$_MOLE_CORE_DIR/base.sh"
|
||||||
|
|
||||||
|
# Declare WHITELIST_PATTERNS if not already set (used by is_path_whitelisted)
|
||||||
|
if ! declare -p WHITELIST_PATTERNS &> /dev/null; then
|
||||||
|
declare -a WHITELIST_PATTERNS=()
|
||||||
|
fi
|
||||||
|
|
||||||
# Application Management
|
# Application Management
|
||||||
|
|
||||||
# Critical system components protected from uninstallation
|
# Critical system components protected from uninstallation
|
||||||
@@ -872,12 +877,24 @@ find_app_system_files() {
|
|||||||
done < <(command find /private/var/db/receipts -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null)
|
done < <(command find /private/var/db/receipts -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
local receipt_files=""
|
||||||
|
receipt_files=$(find_app_receipt_files "$bundle_id")
|
||||||
|
|
||||||
|
local combined_files=""
|
||||||
if [[ ${#system_files[@]} -gt 0 ]]; then
|
if [[ ${#system_files[@]} -gt 0 ]]; then
|
||||||
printf '%s\n' "${system_files[@]}"
|
combined_files=$(printf '%s\n' "${system_files[@]}")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Find files from receipts (Deep Scan)
|
if [[ -n "$receipt_files" ]]; then
|
||||||
find_app_receipt_files "$bundle_id"
|
if [[ -n "$combined_files" ]]; then
|
||||||
|
combined_files+=$'\n'
|
||||||
|
fi
|
||||||
|
combined_files+="$receipt_files"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$combined_files" ]]; then
|
||||||
|
printf '%s\n' "$combined_files" | sort -u
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Locate files using installation receipts (BOM)
|
# Locate files using installation receipts (BOM)
|
||||||
@@ -923,44 +940,37 @@ find_app_receipt_files() {
|
|||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
local is_safe=false
|
local is_safe=false
|
||||||
|
|
||||||
# Whitelisted prefixes
|
# Whitelisted prefixes (exclude /Users, /usr, /opt)
|
||||||
case "$clean_path" in
|
case "$clean_path" in
|
||||||
/Applications/*) is_safe=true ;;
|
/Applications/*) is_safe=true ;;
|
||||||
/Users/*) is_safe=true ;;
|
/Library/Application\ Support/*) is_safe=true ;;
|
||||||
/usr/local/*) is_safe=true ;;
|
/Library/Caches/*) is_safe=true ;;
|
||||||
/opt/*) is_safe=true ;;
|
/Library/Logs/*) is_safe=true ;;
|
||||||
/Library/*)
|
/Library/Preferences/*) is_safe=true ;;
|
||||||
# Filter sub-paths in /Library to avoid system damage
|
/Library/LaunchAgents/*) is_safe=true ;;
|
||||||
# Allow safely: Application Support, Caches, Logs, Preferences
|
/Library/LaunchDaemons/*) is_safe=true ;;
|
||||||
case "$clean_path" in
|
/Library/PrivilegedHelperTools/*) is_safe=true ;;
|
||||||
/Library/Application\ Support/*) is_safe=true ;;
|
/Library/Extensions/*) is_safe=false ;;
|
||||||
/Library/Caches/*) is_safe=true ;;
|
*) is_safe=false ;;
|
||||||
/Library/Logs/*) is_safe=true ;;
|
|
||||||
/Library/Preferences/*) is_safe=true ;;
|
|
||||||
/Library/PrivilegedHelperTools/*) is_safe=true ;;
|
|
||||||
/Library/LaunchAgents/*) is_safe=true ;;
|
|
||||||
/Library/LaunchDaemons/*) is_safe=true ;;
|
|
||||||
/Library/Internet\ Plug-Ins/*) is_safe=true ;;
|
|
||||||
/Library/Audio/Plug-Ins/*) is_safe=true ;;
|
|
||||||
/Library/Extensions/*) is_safe=false ;; # Default unsafe
|
|
||||||
*) is_safe=false ;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Hard blocks
|
# Hard blocks
|
||||||
case "$clean_path" in
|
case "$clean_path" in
|
||||||
/System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/*) is_safe=false ;;
|
/System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/*) is_safe=false ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
if [[ "$is_safe" == "true" && -e "$clean_path" ]]; then
|
if [[ "$is_safe" == "true" && -e "$clean_path" ]]; then
|
||||||
# If lsbom lists /Applications, skip to avoid system damage.
|
# Skip top-level directories
|
||||||
# Extra check: path must be deep enough?
|
if [[ "$clean_path" == "/Applications" || "$clean_path" == "/Library" ]]; then
|
||||||
# If path is just "/Applications", skip.
|
|
||||||
if [[ "$clean_path" == "/Applications" || "$clean_path" == "/Library" || "$clean_path" == "/usr/local" ]]; then
|
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if declare -f should_protect_path > /dev/null 2>&1; then
|
||||||
|
if should_protect_path "$clean_path"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
receipt_files+=("$clean_path")
|
receipt_files+=("$clean_path")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -99,59 +99,6 @@ update_via_homebrew() {
|
|||||||
rm -f "$HOME/.cache/mole/version_check" "$HOME/.cache/mole/update_message" 2> /dev/null || true
|
rm -f "$HOME/.cache/mole/version_check" "$HOME/.cache/mole/update_message" 2> /dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get Homebrew cask name for an application bundle
|
|
||||||
get_brew_cask_name() {
|
|
||||||
local app_path="$1"
|
|
||||||
[[ -z "$app_path" || ! -d "$app_path" ]] && return 1
|
|
||||||
|
|
||||||
# Check if brew command exists
|
|
||||||
command -v brew > /dev/null 2>&1 || return 1
|
|
||||||
|
|
||||||
local app_bundle_name
|
|
||||||
app_bundle_name=$(basename "$app_path")
|
|
||||||
|
|
||||||
# 1. Search in Homebrew Caskroom for the app bundle (most reliable for name mismatches)
|
|
||||||
# Checks /opt/homebrew (Apple Silicon) and /usr/local (Intel)
|
|
||||||
# Note: Modern Homebrew uses symlinks in Caskroom, not directories
|
|
||||||
local cask_match
|
|
||||||
for room in "/opt/homebrew/Caskroom" "/usr/local/Caskroom"; do
|
|
||||||
[[ -d "$room" ]] || continue
|
|
||||||
# Path is room/token/version/App.app (can be directory or symlink)
|
|
||||||
cask_match=$(find "$room" -maxdepth 3 -name "$app_bundle_name" 2> /dev/null | head -1 || echo "")
|
|
||||||
if [[ -n "$cask_match" ]]; then
|
|
||||||
local relative="${cask_match#"$room"/}"
|
|
||||||
echo "${relative%%/*}"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# 2. Check for symlink from Caskroom
|
|
||||||
if [[ -L "$app_path" ]]; then
|
|
||||||
local target
|
|
||||||
target=$(readlink "$app_path")
|
|
||||||
for room in "/opt/homebrew/Caskroom" "/usr/local/Caskroom"; do
|
|
||||||
if [[ "$target" == "$room/"* ]]; then
|
|
||||||
local relative="${target#"$room"/}"
|
|
||||||
echo "${relative%%/*}"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. Fallback: Direct list check (handles some cases where app is moved)
|
|
||||||
local app_name_only="${app_bundle_name%.app}"
|
|
||||||
local cask_name
|
|
||||||
cask_name=$(brew list --cask 2> /dev/null | grep -Fx "$(echo "$app_name_only" | LC_ALL=C tr '[:upper:]' '[:lower:]')" || echo "")
|
|
||||||
if [[ -n "$cask_name" ]]; then
|
|
||||||
if brew info --cask "$cask_name" 2> /dev/null | grep -q "$app_path"; then
|
|
||||||
echo "$cask_name"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Remove applications from Dock
|
# Remove applications from Dock
|
||||||
remove_apps_from_dock() {
|
remove_apps_from_dock() {
|
||||||
if [[ $# -eq 0 ]]; then
|
if [[ $# -eq 0 ]]; then
|
||||||
@@ -172,15 +119,28 @@ remove_apps_from_dock() {
|
|||||||
local plist="$HOME/Library/Preferences/com.apple.dock.plist"
|
local plist="$HOME/Library/Preferences/com.apple.dock.plist"
|
||||||
[[ -f "$plist" ]] || return 0
|
[[ -f "$plist" ]] || return 0
|
||||||
|
|
||||||
command -v PlistBuddy > /dev/null 2>&1 || return 0
|
# PlistBuddy is at /usr/libexec/PlistBuddy on macOS
|
||||||
|
[[ -x /usr/libexec/PlistBuddy ]] || return 0
|
||||||
|
|
||||||
local changed=false
|
local changed=false
|
||||||
for target in "${targets[@]}"; do
|
for target in "${targets[@]}"; do
|
||||||
local app_path="$target"
|
local app_path="$target"
|
||||||
# Normalize path for comparison - realpath might fail if app is already deleted
|
# Normalize path for comparison - use original path if app already deleted
|
||||||
local full_path
|
local full_path
|
||||||
full_path=$(cd "$(dirname "$app_path")" 2> /dev/null && pwd || echo "")
|
if full_path=$(cd "$(dirname "$app_path")" 2> /dev/null && pwd); then
|
||||||
[[ -n "$full_path" ]] && full_path="$full_path/$(basename "$app_path")"
|
full_path="$full_path/$(basename "$app_path")"
|
||||||
|
else
|
||||||
|
# App already deleted - use the original path as-is
|
||||||
|
# Remove ~/ prefix and expand to full path if needed
|
||||||
|
if [[ "$app_path" == ~/* ]]; then
|
||||||
|
full_path="$HOME/${app_path#~/}"
|
||||||
|
elif [[ "$app_path" != /* ]]; then
|
||||||
|
# Relative path - skip this entry
|
||||||
|
continue
|
||||||
|
else
|
||||||
|
full_path="$app_path"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# URL-encode the path for matching against Dock URLs (spaces -> %20)
|
# URL-encode the path for matching against Dock URLs (spaces -> %20)
|
||||||
local encoded_path="${full_path// /%20}"
|
local encoded_path="${full_path// /%20}"
|
||||||
|
|||||||
@@ -66,14 +66,50 @@ validate_path_for_deletion() {
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# Allow known safe paths under /private
|
||||||
|
case "$path" in
|
||||||
|
/private/tmp | /private/tmp/* | \
|
||||||
|
/private/var/tmp | /private/var/tmp/* | \
|
||||||
|
/private/var/log | /private/var/log/* | \
|
||||||
|
/private/var/folders | /private/var/folders/* | \
|
||||||
|
/private/var/db/diagnostics | /private/var/db/diagnostics/* | \
|
||||||
|
/private/var/db/DiagnosticPipeline | /private/var/db/DiagnosticPipeline/* | \
|
||||||
|
/private/var/db/powerlog | /private/var/db/powerlog/* | \
|
||||||
|
/private/var/db/reportmemoryexception | /private/var/db/reportmemoryexception/*)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
# Check path isn't critical system directory
|
# Check path isn't critical system directory
|
||||||
case "$path" in
|
case "$path" in
|
||||||
/ | /bin | /sbin | /usr | /usr/bin | /usr/sbin | /etc | /var | /System | /System/* | /Library/Extensions)
|
/ | /bin | /bin/* | /sbin | /sbin/* | /usr | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /usr/lib | /usr/lib/* | /System | /System/* | /Library/Extensions)
|
||||||
log_error "Path validation failed: critical system directory: $path"
|
log_error "Path validation failed: critical system directory: $path"
|
||||||
return 1
|
return 1
|
||||||
;;
|
;;
|
||||||
|
/private)
|
||||||
|
log_error "Path validation failed: critical system directory: $path"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
/etc | /etc/* | /private/etc | /private/etc/*)
|
||||||
|
log_error "Path validation failed: /etc contains critical system files: $path"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
/var | /var/db | /var/db/* | /private/var | /private/var/db | /private/var/db/*)
|
||||||
|
log_error "Path validation failed: /var/db contains system databases: $path"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# Check if path is protected (keychains, system settings, etc)
|
||||||
|
if declare -f should_protect_path > /dev/null 2>&1; then
|
||||||
|
if should_protect_path "$path"; then
|
||||||
|
if [[ "${MO_DEBUG:-0}" == "1" ]]; then
|
||||||
|
log_warning "Path validation: protected path skipped: $path"
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,11 +66,11 @@ _request_password() {
|
|||||||
|
|
||||||
printf "${PURPLE}${ICON_ARROW}${NC} Password: " > "$tty_path"
|
printf "${PURPLE}${ICON_ARROW}${NC} Password: " > "$tty_path"
|
||||||
|
|
||||||
# Disable terminal echo to hide password input
|
# Disable terminal echo to hide password input (keep canonical mode for reliable input)
|
||||||
stty -echo -icanon min 1 time 0 < "$tty_path" 2> /dev/null || true
|
stty -echo < "$tty_path" 2> /dev/null || true
|
||||||
IFS= read -r password < "$tty_path" || password=""
|
IFS= read -r password < "$tty_path" || password=""
|
||||||
# Restore terminal echo immediately
|
# Restore terminal echo immediately
|
||||||
stty echo icanon < "$tty_path" 2> /dev/null || true
|
stty echo < "$tty_path" 2> /dev/null || true
|
||||||
|
|
||||||
printf "\n" > "$tty_path"
|
printf "\n" > "$tty_path"
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,43 @@ read_key() {
|
|||||||
case "$key" in
|
case "$key" in
|
||||||
$'\n' | $'\r') echo "ENTER" ;;
|
$'\n' | $'\r') echo "ENTER" ;;
|
||||||
$'\x7f' | $'\x08') echo "DELETE" ;;
|
$'\x7f' | $'\x08') echo "DELETE" ;;
|
||||||
$'\x1b') echo "QUIT" ;;
|
$'\x1b')
|
||||||
|
# Check if this is an escape sequence (arrow keys) or ESC key
|
||||||
|
if IFS= read -r -s -n 1 -t 0.1 rest 2> /dev/null; then
|
||||||
|
if [[ "$rest" == "[" ]]; then
|
||||||
|
if IFS= read -r -s -n 1 -t 0.1 rest2 2> /dev/null; then
|
||||||
|
case "$rest2" in
|
||||||
|
"A") echo "UP" ;;
|
||||||
|
"B") echo "DOWN" ;;
|
||||||
|
"C") echo "RIGHT" ;;
|
||||||
|
"D") echo "LEFT" ;;
|
||||||
|
"3")
|
||||||
|
IFS= read -r -s -n 1 -t 0.1 rest3 2> /dev/null
|
||||||
|
[[ "$rest3" == "~" ]] && echo "DELETE" || echo "OTHER"
|
||||||
|
;;
|
||||||
|
*) echo "OTHER" ;;
|
||||||
|
esac
|
||||||
|
else echo "QUIT"; fi
|
||||||
|
elif [[ "$rest" == "O" ]]; then
|
||||||
|
if IFS= read -r -s -n 1 -t 0.1 rest2 2> /dev/null; then
|
||||||
|
case "$rest2" in
|
||||||
|
"A") echo "UP" ;;
|
||||||
|
"B") echo "DOWN" ;;
|
||||||
|
"C") echo "RIGHT" ;;
|
||||||
|
"D") echo "LEFT" ;;
|
||||||
|
*) echo "OTHER" ;;
|
||||||
|
esac
|
||||||
|
else echo "OTHER"; fi
|
||||||
|
else
|
||||||
|
# Not an escape sequence, it's ESC key
|
||||||
|
echo "QUIT"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# No following characters, it's ESC key
|
||||||
|
echo "QUIT"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
' ') echo "SPACE" ;; # Allow space in filter mode for selection
|
||||||
[[:print:]]) echo "CHAR:$key" ;;
|
[[:print:]]) echo "CHAR:$key" ;;
|
||||||
*) echo "OTHER" ;;
|
*) echo "OTHER" ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ uv Python package cache|$HOME/.cache/uv/*|package_manager
|
|||||||
R renv global cache (virtual environments)|$HOME/Library/Caches/org.R-project.R/R/renv/*|package_manager
|
R renv global cache (virtual environments)|$HOME/Library/Caches/org.R-project.R/R/renv/*|package_manager
|
||||||
Homebrew downloaded packages|$HOME/Library/Caches/Homebrew/*|package_manager
|
Homebrew downloaded packages|$HOME/Library/Caches/Homebrew/*|package_manager
|
||||||
Yarn package manager cache|$HOME/.cache/yarn/*|package_manager
|
Yarn package manager cache|$HOME/.cache/yarn/*|package_manager
|
||||||
pnpm package store|$HOME/.pnpm-store/*|package_manager
|
pnpm package store|$HOME/Library/pnpm/store/*|package_manager
|
||||||
Composer PHP dependencies cache|$HOME/.composer/cache/*|package_manager
|
Composer PHP dependencies cache|$HOME/.composer/cache/*|package_manager
|
||||||
RubyGems cache|$HOME/.gem/cache/*|package_manager
|
RubyGems cache|$HOME/.gem/cache/*|package_manager
|
||||||
Conda packages cache|$HOME/.conda/pkgs/*|package_manager
|
Conda packages cache|$HOME/.conda/pkgs/*|package_manager
|
||||||
|
|||||||
@@ -201,7 +201,6 @@ paginated_multi_select() {
|
|||||||
export MOLE_MENU_SORT_MODE="$sort_mode"
|
export MOLE_MENU_SORT_MODE="$sort_mode"
|
||||||
export MOLE_MENU_SORT_REVERSE="$sort_reverse"
|
export MOLE_MENU_SORT_REVERSE="$sort_reverse"
|
||||||
restore_terminal
|
restore_terminal
|
||||||
unset MOLE_READ_KEY_FORCE_CHAR
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Interrupt handler
|
# Interrupt handler
|
||||||
@@ -595,7 +594,6 @@ paginated_multi_select() {
|
|||||||
"QUIT")
|
"QUIT")
|
||||||
if [[ "$filter_mode" == "true" ]]; then
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
filter_mode="false"
|
filter_mode="false"
|
||||||
unset MOLE_READ_KEY_FORCE_CHAR
|
|
||||||
filter_query=""
|
filter_query=""
|
||||||
applied_query=""
|
applied_query=""
|
||||||
top_index=0
|
top_index=0
|
||||||
@@ -764,7 +762,9 @@ paginated_multi_select() {
|
|||||||
if [[ "$filter_mode" == "true" ]]; then
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
local ch="${key#CHAR:}"
|
local ch="${key#CHAR:}"
|
||||||
filter_query+="$ch"
|
filter_query+="$ch"
|
||||||
|
rebuild_view
|
||||||
need_full_redraw=true
|
need_full_redraw=true
|
||||||
|
continue
|
||||||
elif [[ "$has_metadata" == "true" ]]; then
|
elif [[ "$has_metadata" == "true" ]]; then
|
||||||
# Cycle sort mode (only if metadata available)
|
# Cycle sort mode (only if metadata available)
|
||||||
case "$sort_mode" in
|
case "$sort_mode" in
|
||||||
@@ -789,7 +789,6 @@ paginated_multi_select() {
|
|||||||
else
|
else
|
||||||
# Enter filter mode
|
# Enter filter mode
|
||||||
filter_mode="true"
|
filter_mode="true"
|
||||||
export MOLE_READ_KEY_FORCE_CHAR=1
|
|
||||||
filter_query=""
|
filter_query=""
|
||||||
top_index=0
|
top_index=0
|
||||||
cursor_pos=0
|
cursor_pos=0
|
||||||
@@ -815,6 +814,9 @@ paginated_multi_select() {
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
filter_query+="j"
|
filter_query+="j"
|
||||||
|
rebuild_view
|
||||||
|
need_full_redraw=true
|
||||||
|
continue
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
"CHAR:k")
|
"CHAR:k")
|
||||||
@@ -829,17 +831,66 @@ paginated_multi_select() {
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
filter_query+="k"
|
filter_query+="k"
|
||||||
|
rebuild_view
|
||||||
|
need_full_redraw=true
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"TOUCHID")
|
||||||
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
|
filter_query+="t"
|
||||||
|
rebuild_view
|
||||||
|
need_full_redraw=true
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"RIGHT")
|
||||||
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
|
filter_query+="l"
|
||||||
|
rebuild_view
|
||||||
|
need_full_redraw=true
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"LEFT")
|
||||||
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
|
filter_query+="h"
|
||||||
|
rebuild_view
|
||||||
|
need_full_redraw=true
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"MORE")
|
||||||
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
|
filter_query+="m"
|
||||||
|
rebuild_view
|
||||||
|
need_full_redraw=true
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"UPDATE")
|
||||||
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
|
filter_query+="u"
|
||||||
|
rebuild_view
|
||||||
|
need_full_redraw=true
|
||||||
|
continue
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
"CHAR:f" | "CHAR:F")
|
"CHAR:f" | "CHAR:F")
|
||||||
if [[ "$filter_mode" == "true" ]]; then
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
filter_query+="${key#CHAR:}"
|
filter_query+="${key#CHAR:}"
|
||||||
|
rebuild_view
|
||||||
|
need_full_redraw=true
|
||||||
|
continue
|
||||||
fi
|
fi
|
||||||
# F is currently unbound in normal mode to avoid conflict with Refresh (R)
|
# F is currently unbound in normal mode to avoid conflict with Refresh (R)
|
||||||
;;
|
;;
|
||||||
"CHAR:r" | "CHAR:R")
|
"CHAR:r" | "CHAR:R")
|
||||||
if [[ "$filter_mode" == "true" ]]; then
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
filter_query+="${key#CHAR:}"
|
filter_query+="${key#CHAR:}"
|
||||||
|
rebuild_view
|
||||||
|
need_full_redraw=true
|
||||||
|
continue
|
||||||
else
|
else
|
||||||
# Trigger Refresh signal (Unified with Analyze)
|
# Trigger Refresh signal (Unified with Analyze)
|
||||||
cleanup
|
cleanup
|
||||||
@@ -849,6 +900,9 @@ paginated_multi_select() {
|
|||||||
"CHAR:o" | "CHAR:O")
|
"CHAR:o" | "CHAR:O")
|
||||||
if [[ "$filter_mode" == "true" ]]; then
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
filter_query+="${key#CHAR:}"
|
filter_query+="${key#CHAR:}"
|
||||||
|
rebuild_view
|
||||||
|
need_full_redraw=true
|
||||||
|
continue
|
||||||
elif [[ "$has_metadata" == "true" ]]; then
|
elif [[ "$has_metadata" == "true" ]]; then
|
||||||
# O toggles reverse order (Unified Sort Order)
|
# O toggles reverse order (Unified Sort Order)
|
||||||
if [[ "$sort_reverse" == "true" ]]; then
|
if [[ "$sort_reverse" == "true" ]]; then
|
||||||
@@ -864,13 +918,10 @@ paginated_multi_select() {
|
|||||||
# Backspace filter
|
# Backspace filter
|
||||||
if [[ "$filter_mode" == "true" && -n "$filter_query" ]]; then
|
if [[ "$filter_mode" == "true" && -n "$filter_query" ]]; then
|
||||||
filter_query="${filter_query%?}"
|
filter_query="${filter_query%?}"
|
||||||
# Fast footer-only update in filter mode (avoid full redraw)
|
# Rebuild view to apply filter in real-time
|
||||||
local filter_status="${filter_query:-_}"
|
rebuild_view
|
||||||
local footer_row=$((items_per_page + 4))
|
# Trigger redraw and continue to avoid drain_pending_input
|
||||||
printf "\033[%d;1H\033[2K" "$footer_row" >&2
|
need_full_redraw=true
|
||||||
local sep=" ${GRAY}|${NC} "
|
|
||||||
printf "%s" "${GRAY}Search: ${filter_status}${NC}${sep}${GRAY}Delete${NC}${sep}${GRAY}Enter Confirm${NC}${sep}${GRAY}ESC Cancel${NC}" >&2
|
|
||||||
printf "\033[%d;1H\033[2K" "$((footer_row + 1))" >&2
|
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
@@ -880,13 +931,10 @@ paginated_multi_select() {
|
|||||||
# avoid accidental leading spaces
|
# avoid accidental leading spaces
|
||||||
if [[ -n "$filter_query" || "$ch" != " " ]]; then
|
if [[ -n "$filter_query" || "$ch" != " " ]]; then
|
||||||
filter_query+="$ch"
|
filter_query+="$ch"
|
||||||
# Fast footer-only update in filter mode (avoid full redraw)
|
# Rebuild view to apply filter in real-time
|
||||||
local filter_status="${filter_query:-_}"
|
rebuild_view
|
||||||
local footer_row=$((items_per_page + 4))
|
# Trigger redraw and continue to avoid drain_pending_input
|
||||||
printf "\033[%d;1H\033[2K" "$footer_row" >&2
|
need_full_redraw=true
|
||||||
local sep=" ${GRAY}|${NC} "
|
|
||||||
printf "%s" "${GRAY}Search: ${filter_status}${NC}${sep}${GRAY}Delete${NC}${sep}${GRAY}Enter Confirm${NC}${sep}${GRAY}ESC Cancel${NC}" >&2
|
|
||||||
printf "\033[%d;1H\033[2K" "$((footer_row + 1))" >&2
|
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -895,17 +943,9 @@ paginated_multi_select() {
|
|||||||
if [[ "$filter_mode" == "true" ]]; then
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
applied_query="$filter_query"
|
applied_query="$filter_query"
|
||||||
filter_mode="false"
|
filter_mode="false"
|
||||||
unset MOLE_READ_KEY_FORCE_CHAR
|
# Preserve cursor/top_index so navigation during search is respected
|
||||||
top_index=0
|
|
||||||
cursor_pos=0
|
|
||||||
|
|
||||||
searching="true"
|
|
||||||
draw_menu # paint "searching..."
|
|
||||||
drain_pending_input # drop any extra keypresses (e.g., double-Enter)
|
|
||||||
rebuild_view
|
rebuild_view
|
||||||
searching="false"
|
# Fall through to confirmation logic
|
||||||
draw_menu
|
|
||||||
continue
|
|
||||||
fi
|
fi
|
||||||
# In normal mode: smart Enter behavior
|
# In normal mode: smart Enter behavior
|
||||||
# 1. Check if any items are already selected
|
# 1. Check if any items are already selected
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Ensure common.sh is loaded.
|
# Ensure common.sh is loaded.
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
[[ -z "${MOLE_COMMON_LOADED:-}" ]] && source "$SCRIPT_DIR/lib/core/common.sh"
|
[[ -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.
|
# Batch uninstall with a single confirmation.
|
||||||
|
|
||||||
# User data detection patterns (prompt user to backup if found).
|
# User data detection patterns (prompt user to backup if found).
|
||||||
@@ -101,7 +104,11 @@ remove_login_item() {
|
|||||||
|
|
||||||
# Remove from Login Items using index-based deletion (handles broken items)
|
# Remove from Login Items using index-based deletion (handles broken items)
|
||||||
if [[ -n "$clean_name" ]]; then
|
if [[ -n "$clean_name" ]]; then
|
||||||
osascript <<- EOF 2> /dev/null || true
|
# 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"
|
tell application "System Events"
|
||||||
try
|
try
|
||||||
set itemCount to count of login items
|
set itemCount to count of login items
|
||||||
@@ -109,7 +116,7 @@ remove_login_item() {
|
|||||||
repeat with i from itemCount to 1 by -1
|
repeat with i from itemCount to 1 by -1
|
||||||
try
|
try
|
||||||
set itemName to name of login item i
|
set itemName to name of login item i
|
||||||
if itemName is "$clean_name" then
|
if itemName is "$escaped_name" then
|
||||||
delete login item i
|
delete login item i
|
||||||
end if
|
end if
|
||||||
end try
|
end try
|
||||||
@@ -129,17 +136,21 @@ remove_file_list() {
|
|||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
[[ -n "$file" && -e "$file" ]] || continue
|
[[ -n "$file" && -e "$file" ]] || continue
|
||||||
|
|
||||||
|
if ! validate_path_for_deletion "$file"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -L "$file" ]]; then
|
if [[ -L "$file" ]]; then
|
||||||
if [[ "$use_sudo" == "true" ]]; then
|
if [[ "$use_sudo" == "true" ]]; then
|
||||||
sudo rm "$file" 2> /dev/null && ((count++)) || true
|
sudo rm "$file" 2> /dev/null && ((++count)) || true
|
||||||
else
|
else
|
||||||
rm "$file" 2> /dev/null && ((count++)) || true
|
rm "$file" 2> /dev/null && ((++count)) || true
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
if [[ "$use_sudo" == "true" ]]; then
|
if [[ "$use_sudo" == "true" ]]; then
|
||||||
safe_sudo_remove "$file" && ((count++)) || true
|
safe_sudo_remove "$file" && ((++count)) || true
|
||||||
else
|
else
|
||||||
safe_remove "$file" true && ((count++)) || true
|
safe_remove "$file" true && ((++count)) || true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done <<< "$file_list"
|
done <<< "$file_list"
|
||||||
@@ -178,72 +189,57 @@ batch_uninstall_applications() {
|
|||||||
running_apps+=("$app_name")
|
running_apps+=("$app_name")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if it's a Homebrew cask
|
# Check if it's a Homebrew cask (deterministic: resolved path in Caskroom)
|
||||||
local cask_name=""
|
local cask_name=""
|
||||||
cask_name=$(get_brew_cask_name "$app_path" || echo "")
|
cask_name=$(get_brew_cask_name "$app_path" || echo "")
|
||||||
local is_brew_cask="false"
|
local is_brew_cask="false"
|
||||||
[[ -n "$cask_name" ]] && is_brew_cask="true"
|
[[ -n "$cask_name" ]] && is_brew_cask="true"
|
||||||
|
|
||||||
# For Homebrew casks, skip detailed file scanning since brew handles it
|
# Full file scanning for ALL apps (including Homebrew casks)
|
||||||
if [[ "$is_brew_cask" == "true" ]]; then
|
# brew uninstall --cask does NOT remove user data (caches, prefs, app support)
|
||||||
local app_size_kb=$(get_path_size_kb "$app_path")
|
# Mole's value is cleaning those up, so we must scan for them
|
||||||
local total_kb=$app_size_kb
|
local needs_sudo=false
|
||||||
((total_estimated_size += total_kb))
|
local app_owner=$(get_file_owner "$app_path")
|
||||||
|
local current_user=$(whoami)
|
||||||
# Homebrew may need sudo for system-wide installations
|
if [[ ! -w "$(dirname "$app_path")" ]] ||
|
||||||
local needs_sudo=false
|
[[ "$app_owner" == "root" ]] ||
|
||||||
if [[ "$app_path" == "/Applications/"* ]]; then
|
[[ -n "$app_owner" && "$app_owner" != "$current_user" ]]; then
|
||||||
needs_sudo=true
|
needs_sudo=true
|
||||||
sudo_apps+=("$app_name")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Store minimal details for Homebrew apps
|
|
||||||
app_details+=("$app_name|$app_path|$bundle_id|$total_kb|||false|$needs_sudo|$is_brew_cask|$cask_name")
|
|
||||||
else
|
|
||||||
# For non-Homebrew apps, do full file scanning
|
|
||||||
local needs_sudo=false
|
|
||||||
local app_owner=$(get_file_owner "$app_path")
|
|
||||||
local current_user=$(whoami)
|
|
||||||
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 [[ -n "$related_files" ]] && echo "$related_files" | grep -qE "$SENSITIVE_DATA_REGEX"; 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")
|
|
||||||
fi
|
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 [[ -n "$related_files" ]] && echo "$related_files" | grep -qE "$SENSITIVE_DATA_REGEX"; 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
|
done
|
||||||
if [[ -t 1 ]]; then stop_inline_spinner; fi
|
if [[ -t 1 ]]; then stop_inline_spinner; fi
|
||||||
|
|
||||||
@@ -276,42 +272,39 @@ batch_uninstall_applications() {
|
|||||||
[[ "$is_brew_cask" == "true" ]] && brew_tag=" ${CYAN}[Brew]${NC}"
|
[[ "$is_brew_cask" == "true" ]] && brew_tag=" ${CYAN}[Brew]${NC}"
|
||||||
echo -e "${BLUE}${ICON_CONFIRM}${NC} ${app_name}${brew_tag} ${GRAY}(${app_size_display})${NC}"
|
echo -e "${BLUE}${ICON_CONFIRM}${NC} ${app_name}${brew_tag} ${GRAY}(${app_size_display})${NC}"
|
||||||
|
|
||||||
# For Homebrew apps, [Brew] tag is enough indication
|
# Show detailed file list for ALL apps (brew casks leave user data behind)
|
||||||
# For non-Homebrew apps, show detailed file list
|
local related_files=$(decode_file_list "$encoded_files" "$app_name")
|
||||||
if [[ "$is_brew_cask" != "true" ]]; then
|
local system_files=$(decode_file_list "$encoded_system_files" "$app_name")
|
||||||
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/~}"
|
echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${app_path/$HOME/~}"
|
||||||
|
|
||||||
# Show related files (limit to 5).
|
# Show related files (limit to 5).
|
||||||
local file_count=0
|
local file_count=0
|
||||||
local max_files=5
|
local max_files=5
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
if [[ -n "$file" && -e "$file" ]]; then
|
if [[ -n "$file" && -e "$file" ]]; then
|
||||||
if [[ $file_count -lt $max_files ]]; then
|
if [[ $file_count -lt $max_files ]]; then
|
||||||
echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${file/$HOME/~}"
|
echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${file/$HOME/~}"
|
||||||
fi
|
|
||||||
((file_count++))
|
|
||||||
fi
|
fi
|
||||||
done <<< "$related_files"
|
((file_count++))
|
||||||
|
|
||||||
# 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
|
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
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -370,6 +363,7 @@ batch_uninstall_applications() {
|
|||||||
|
|
||||||
# Perform uninstallations with per-app progress feedback
|
# Perform uninstallations with per-app progress feedback
|
||||||
local success_count=0 failed_count=0
|
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 failed_items=()
|
||||||
local -a success_items=()
|
local -a success_items=()
|
||||||
local current_index=0
|
local current_index=0
|
||||||
@@ -394,6 +388,8 @@ batch_uninstall_applications() {
|
|||||||
# Stop Launch Agents/Daemons before removal.
|
# Stop Launch Agents/Daemons before removal.
|
||||||
local has_system_files="false"
|
local has_system_files="false"
|
||||||
[[ -n "$system_files" ]] && has_system_files="true"
|
[[ -n "$system_files" ]] && has_system_files="true"
|
||||||
|
|
||||||
|
|
||||||
stop_launch_services "$bundle_id" "$has_system_files"
|
stop_launch_services "$bundle_id" "$has_system_files"
|
||||||
|
|
||||||
# Remove from Login Items
|
# Remove from Login Items
|
||||||
@@ -404,24 +400,16 @@ batch_uninstall_applications() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove the application only if not running.
|
# Remove the application only if not running.
|
||||||
|
local used_brew_successfully=false
|
||||||
if [[ -z "$reason" ]]; then
|
if [[ -z "$reason" ]]; then
|
||||||
if [[ "$is_brew_cask" == "true" && -n "$cask_name" ]]; then
|
if [[ "$is_brew_cask" == "true" && -n "$cask_name" ]]; then
|
||||||
# Stop spinner before brew output
|
# Stop spinner before brew output
|
||||||
if [[ -t 1 ]]; then
|
[[ -t 1 ]] && stop_inline_spinner
|
||||||
stop_inline_spinner
|
# Use brew_uninstall_cask helper (handles env vars, timeout, verification)
|
||||||
fi
|
if brew_uninstall_cask "$cask_name" "$app_path"; then
|
||||||
|
used_brew_successfully=true
|
||||||
# Use brew uninstall --cask - show output directly
|
else
|
||||||
local brew_failed=false
|
# Fallback to manual removal if brew fails
|
||||||
if ! run_with_timeout 120 brew uninstall --cask "$cask_name" 2>&1; then
|
|
||||||
brew_failed=true
|
|
||||||
log_warning "brew uninstall failed for $app_name, falling back to manual cleanup"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$brew_failed" == "true" ]]; then
|
|
||||||
# Fallback to manual cleanup
|
|
||||||
[[ -z "$related_files" ]] && related_files=$(find_app_files "$bundle_id" "$app_name")
|
|
||||||
[[ -z "$system_files" ]] && system_files=$(find_app_system_files "$bundle_id" "$app_name")
|
|
||||||
if [[ "$needs_sudo" == true ]]; then
|
if [[ "$needs_sudo" == true ]]; then
|
||||||
safe_sudo_remove "$app_path" || reason="remove failed"
|
safe_sudo_remove "$app_path" || reason="remove failed"
|
||||||
else
|
else
|
||||||
@@ -446,7 +434,13 @@ batch_uninstall_applications() {
|
|||||||
# Remove related files if app removal succeeded.
|
# Remove related files if app removal succeeded.
|
||||||
if [[ -z "$reason" ]]; then
|
if [[ -z "$reason" ]]; then
|
||||||
remove_file_list "$related_files" "false" > /dev/null
|
remove_file_list "$related_files" "false" > /dev/null
|
||||||
remove_file_list "$system_files" "true" > /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).
|
# Clean up macOS defaults (preference domains).
|
||||||
if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then
|
if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then
|
||||||
@@ -472,6 +466,7 @@ batch_uninstall_applications() {
|
|||||||
|
|
||||||
((total_size_freed += total_kb))
|
((total_size_freed += total_kb))
|
||||||
((success_count++))
|
((success_count++))
|
||||||
|
[[ "$used_brew_successfully" == "true" ]] && ((brew_apps_removed++))
|
||||||
((files_cleaned++))
|
((files_cleaned++))
|
||||||
((total_items++))
|
((total_items++))
|
||||||
success_items+=("$app_name")
|
success_items+=("$app_name")
|
||||||
@@ -577,6 +572,28 @@ batch_uninstall_applications() {
|
|||||||
print_summary_block "$title" "${summary_details[@]}"
|
print_summary_block "$title" "${summary_details[@]}"
|
||||||
printf '\n'
|
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.
|
# Clean up Dock entries for uninstalled apps.
|
||||||
if [[ $success_count -gt 0 ]]; then
|
if [[ $success_count -gt 0 ]]; then
|
||||||
local -a removed_paths=()
|
local -a removed_paths=()
|
||||||
|
|||||||
210
lib/uninstall/brew.sh
Normal file
210
lib/uninstall/brew.sh
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Mole - Homebrew Cask Uninstallation Support
|
||||||
|
# Detects Homebrew-managed casks via Caskroom linkage and uninstalls them via brew
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Prevent multiple sourcing
|
||||||
|
if [[ -n "${MOLE_BREW_UNINSTALL_LOADED:-}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
readonly MOLE_BREW_UNINSTALL_LOADED=1
|
||||||
|
|
||||||
|
# Resolve a path to its absolute real path (follows symlinks)
|
||||||
|
# Args: $1 - path to resolve
|
||||||
|
# Returns: Absolute resolved path, or empty string on failure
|
||||||
|
resolve_path() {
|
||||||
|
local p="$1"
|
||||||
|
[[ -e "$p" ]] || return 1
|
||||||
|
|
||||||
|
# macOS 12.3+ and Linux have realpath
|
||||||
|
if realpath "$p" 2> /dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback: use cd -P to resolve directory, then append basename
|
||||||
|
local dir base
|
||||||
|
dir=$(cd -P "$(dirname "$p")" 2> /dev/null && pwd) || return 1
|
||||||
|
base=$(basename "$p")
|
||||||
|
echo "$dir/$base"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if Homebrew is installed and accessible
|
||||||
|
# Returns: 0 if brew is available, 1 otherwise
|
||||||
|
is_homebrew_available() {
|
||||||
|
command -v brew > /dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract cask token from a Caskroom path
|
||||||
|
# Args: $1 - path (must be inside Caskroom)
|
||||||
|
# Prints: cask token to stdout
|
||||||
|
# Returns: 0 if valid token extracted, 1 otherwise
|
||||||
|
_extract_cask_token_from_path() {
|
||||||
|
local path="$1"
|
||||||
|
|
||||||
|
# Check if path is inside Caskroom
|
||||||
|
case "$path" in
|
||||||
|
/opt/homebrew/Caskroom/* | /usr/local/Caskroom/*) ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Extract token from path: /opt/homebrew/Caskroom/<token>/<version>/...
|
||||||
|
local token
|
||||||
|
token="${path#*/Caskroom/}" # Remove everything up to and including Caskroom/
|
||||||
|
token="${token%%/*}" # Take only the first path component
|
||||||
|
|
||||||
|
# Validate token looks like a valid cask name (lowercase alphanumeric with hyphens)
|
||||||
|
if [[ -n "$token" && "$token" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
|
||||||
|
echo "$token"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stage 1: Deterministic detection via fully resolved path
|
||||||
|
# Fast, no false positives - follows all symlinks
|
||||||
|
_detect_cask_via_resolved_path() {
|
||||||
|
local app_path="$1"
|
||||||
|
local resolved
|
||||||
|
if resolved=$(resolve_path "$app_path") && [[ -n "$resolved" ]]; then
|
||||||
|
_extract_cask_token_from_path "$resolved" && return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stage 2: Search Caskroom by app bundle name using find
|
||||||
|
# Catches apps where the .app in /Applications doesn't link to Caskroom
|
||||||
|
# Only succeeds if exactly one cask matches (avoids wrong uninstall)
|
||||||
|
_detect_cask_via_caskroom_search() {
|
||||||
|
local app_bundle_name="$1"
|
||||||
|
[[ -z "$app_bundle_name" ]] && return 1
|
||||||
|
|
||||||
|
local -a tokens=()
|
||||||
|
local room match token
|
||||||
|
|
||||||
|
for room in "/opt/homebrew/Caskroom" "/usr/local/Caskroom"; do
|
||||||
|
[[ -d "$room" ]] || continue
|
||||||
|
while IFS= read -r match; do
|
||||||
|
[[ -n "$match" ]] || continue
|
||||||
|
token=$(_extract_cask_token_from_path "$match" 2> /dev/null) || continue
|
||||||
|
[[ -n "$token" ]] && tokens+=("$token")
|
||||||
|
done < <(find "$room" -maxdepth 3 -name "$app_bundle_name" 2> /dev/null)
|
||||||
|
done
|
||||||
|
|
||||||
|
# Need at least one token
|
||||||
|
((${#tokens[@]} > 0)) || return 1
|
||||||
|
|
||||||
|
# Deduplicate and check count
|
||||||
|
local -a uniq
|
||||||
|
IFS=$'\n' read -r -d '' -a uniq < <(printf '%s\n' "${tokens[@]}" | sort -u && printf '\0') || true
|
||||||
|
|
||||||
|
# Only succeed if exactly one unique token found and it's installed
|
||||||
|
if ((${#uniq[@]} == 1)) && [[ -n "${uniq[0]}" ]]; then
|
||||||
|
HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "${uniq[0]}" || return 1
|
||||||
|
echo "${uniq[0]}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stage 3: Check if app_path is a direct symlink to Caskroom
|
||||||
|
_detect_cask_via_symlink_check() {
|
||||||
|
local app_path="$1"
|
||||||
|
[[ -L "$app_path" ]] || return 1
|
||||||
|
|
||||||
|
local target
|
||||||
|
target=$(readlink "$app_path" 2> /dev/null) || return 1
|
||||||
|
_extract_cask_token_from_path "$target"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stage 4: Query brew list --cask and verify with brew info (slowest fallback)
|
||||||
|
_detect_cask_via_brew_list() {
|
||||||
|
local app_path="$1"
|
||||||
|
local app_bundle_name="$2"
|
||||||
|
local app_name_lower
|
||||||
|
app_name_lower=$(echo "${app_bundle_name%.app}" | LC_ALL=C tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
|
local cask_name
|
||||||
|
cask_name=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -Fix "$app_name_lower") || return 1
|
||||||
|
|
||||||
|
# Verify this cask actually owns this app path
|
||||||
|
HOMEBREW_NO_ENV_HINTS=1 brew info --cask "$cask_name" 2> /dev/null | grep -qF "$app_path" || return 1
|
||||||
|
echo "$cask_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get Homebrew cask name for an app
|
||||||
|
# Uses multi-stage detection (fast to slow, deterministic to heuristic):
|
||||||
|
# 1. Resolve symlinks fully, check if path is in Caskroom (fast, deterministic)
|
||||||
|
# 2. Search Caskroom by app bundle name using find
|
||||||
|
# 3. Check if app is a direct symlink to Caskroom
|
||||||
|
# 4. Query brew list --cask and verify with brew info (slowest)
|
||||||
|
#
|
||||||
|
# Args: $1 - app_path
|
||||||
|
# Prints: cask token to stdout if brew-managed
|
||||||
|
# Returns: 0 if Homebrew-managed, 1 otherwise
|
||||||
|
get_brew_cask_name() {
|
||||||
|
local app_path="$1"
|
||||||
|
[[ -z "$app_path" || ! -e "$app_path" ]] && return 1
|
||||||
|
is_homebrew_available || return 1
|
||||||
|
|
||||||
|
local app_bundle_name
|
||||||
|
app_bundle_name=$(basename "$app_path")
|
||||||
|
|
||||||
|
# Try each detection method in order (fast to slow)
|
||||||
|
_detect_cask_via_resolved_path "$app_path" && return 0
|
||||||
|
_detect_cask_via_caskroom_search "$app_bundle_name" && return 0
|
||||||
|
_detect_cask_via_symlink_check "$app_path" && return 0
|
||||||
|
_detect_cask_via_brew_list "$app_path" "$app_bundle_name" && return 0
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uninstall a Homebrew cask and verify removal
|
||||||
|
# Args: $1 - cask_name, $2 - app_path (optional, for verification)
|
||||||
|
# Returns: 0 on success, 1 on failure
|
||||||
|
brew_uninstall_cask() {
|
||||||
|
local cask_name="$1"
|
||||||
|
local app_path="${2:-}"
|
||||||
|
|
||||||
|
is_homebrew_available || return 1
|
||||||
|
[[ -z "$cask_name" ]] && return 1
|
||||||
|
|
||||||
|
debug_log "Attempting brew uninstall --cask $cask_name"
|
||||||
|
|
||||||
|
# Run uninstall with timeout (suppress hints/auto-update)
|
||||||
|
debug_log "Attempting brew uninstall --cask $cask_name"
|
||||||
|
|
||||||
|
# Ensure we have sudo access if needed, to prevent brew from hanging on password prompt
|
||||||
|
# Many brew casks need sudo to uninstall
|
||||||
|
if ! sudo -n true 2> /dev/null; then
|
||||||
|
# If we don't have sudo, try to get it (visibly)
|
||||||
|
sudo -v
|
||||||
|
fi
|
||||||
|
|
||||||
|
local uninstall_ok=false
|
||||||
|
|
||||||
|
# Run directly without output capture to allow user interaction/visibility
|
||||||
|
# This avoids silence/hangs when brew asks for passwords or confirmation
|
||||||
|
if HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \
|
||||||
|
brew uninstall --cask "$cask_name"; then
|
||||||
|
uninstall_ok=true
|
||||||
|
else
|
||||||
|
debug_log "brew uninstall failed with exit code $?"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify removal
|
||||||
|
local cask_gone=true app_gone=true
|
||||||
|
HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "$cask_name" && cask_gone=false
|
||||||
|
[[ -n "$app_path" && -e "$app_path" ]] && app_gone=false
|
||||||
|
|
||||||
|
# Success: uninstall worked and both are gone, or already uninstalled
|
||||||
|
if $cask_gone && $app_gone; then
|
||||||
|
debug_log "Successfully uninstalled cask '$cask_name'"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
debug_log "brew uninstall failed: cask_gone=$cask_gone app_gone=$app_gone"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ setup() {
|
|||||||
|
|
||||||
run bash <<EOF
|
run bash <<EOF
|
||||||
source "$PROJECT_ROOT/lib/core/common.sh"
|
source "$PROJECT_ROOT/lib/core/common.sh"
|
||||||
|
source "$PROJECT_ROOT/lib/uninstall/brew.sh"
|
||||||
|
|
||||||
# Override the function to use our test Caskroom
|
# Override the function to use our test Caskroom
|
||||||
get_brew_cask_name() {
|
get_brew_cask_name() {
|
||||||
@@ -62,6 +63,7 @@ EOF
|
|||||||
|
|
||||||
result=$(bash <<EOF
|
result=$(bash <<EOF
|
||||||
source "$PROJECT_ROOT/lib/core/common.sh"
|
source "$PROJECT_ROOT/lib/core/common.sh"
|
||||||
|
source "$PROJECT_ROOT/lib/uninstall/brew.sh"
|
||||||
# Mock brew to return nothing for this
|
# Mock brew to return nothing for this
|
||||||
brew() { return 1; }
|
brew() { return 1; }
|
||||||
export -f brew
|
export -f brew
|
||||||
|
|||||||
@@ -114,3 +114,4 @@ EOF
|
|||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
[[ "$output" == "ok" ]]
|
[[ "$output" == "ok" ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -264,91 +264,4 @@ EOF
|
|||||||
[[ "$output" == *"Time Machine backup in progress, skipping cleanup"* ]]
|
[[ "$output" == *"Time Machine backup in progress, skipping cleanup"* ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "clean_empty_library_items removes nested empty directories in Application Support" {
|
|
||||||
# Create nested empty directory structure
|
|
||||||
mkdir -p "$HOME/Library/Application Support/UninstalledApp1/SubDir/DeepDir"
|
|
||||||
mkdir -p "$HOME/Library/Application Support/UninstalledApp2/Cache"
|
|
||||||
mkdir -p "$HOME/Library/Application Support/ActiveApp/Data"
|
|
||||||
mkdir -p "$HOME/Library/Caches/EmptyCache/SubCache"
|
|
||||||
|
|
||||||
# Create a file in ActiveApp to make it non-empty
|
|
||||||
touch "$HOME/Library/Application Support/ActiveApp/Data/config.json"
|
|
||||||
|
|
||||||
# Create top-level empty directory in Library
|
|
||||||
mkdir -p "$HOME/Library/EmptyTopLevel"
|
|
||||||
|
|
||||||
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF'
|
|
||||||
set -euo pipefail
|
|
||||||
source "$PROJECT_ROOT/lib/core/common.sh"
|
|
||||||
source "$PROJECT_ROOT/lib/clean/user.sh"
|
|
||||||
|
|
||||||
# Mock dependencies
|
|
||||||
is_path_whitelisted() { return 1; }
|
|
||||||
is_critical_system_component() { return 1; }
|
|
||||||
bytes_to_human() { echo "$1"; }
|
|
||||||
note_activity() { :; }
|
|
||||||
safe_clean() {
|
|
||||||
# Actually remove the directories for testing
|
|
||||||
for path in "$@"; do
|
|
||||||
if [ "$path" != "${@: -1}" ]; then # Skip the description (last arg)
|
|
||||||
rm -rf "$path" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
clean_empty_library_items
|
|
||||||
EOF
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
|
|
||||||
# Empty nested dirs should be removed
|
|
||||||
[ ! -d "$HOME/Library/Application Support/UninstalledApp1" ]
|
|
||||||
[ ! -d "$HOME/Library/Application Support/UninstalledApp2" ]
|
|
||||||
[ ! -d "$HOME/Library/Caches/EmptyCache" ]
|
|
||||||
[ ! -d "$HOME/Library/EmptyTopLevel" ]
|
|
||||||
|
|
||||||
# Non-empty directory should remain
|
|
||||||
[ -d "$HOME/Library/Application Support/ActiveApp" ]
|
|
||||||
[ -f "$HOME/Library/Application Support/ActiveApp/Data/config.json" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "clean_empty_library_items respects whitelist for empty directories" {
|
|
||||||
mkdir -p "$HOME/Library/Application Support/ProtectedEmptyApp"
|
|
||||||
mkdir -p "$HOME/Library/Application Support/UnprotectedEmptyApp"
|
|
||||||
mkdir -p "$HOME/.config/mole"
|
|
||||||
|
|
||||||
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF'
|
|
||||||
set -euo pipefail
|
|
||||||
source "$PROJECT_ROOT/lib/core/common.sh"
|
|
||||||
source "$PROJECT_ROOT/lib/clean/user.sh"
|
|
||||||
|
|
||||||
# Mock dependencies
|
|
||||||
is_critical_system_component() { return 1; }
|
|
||||||
bytes_to_human() { echo "$1"; }
|
|
||||||
note_activity() { :; }
|
|
||||||
|
|
||||||
# Mock whitelist to protect ProtectedEmptyApp
|
|
||||||
is_path_whitelisted() {
|
|
||||||
[[ "$1" == *"ProtectedEmptyApp"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
safe_clean() {
|
|
||||||
# Actually remove the directories for testing
|
|
||||||
for path in "$@"; do
|
|
||||||
if [ "$path" != "${@: -1}" ]; then # Skip the description (last arg)
|
|
||||||
rm -rf "$path" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
clean_empty_library_items
|
|
||||||
EOF
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
|
|
||||||
# Whitelisted directory should remain even if empty
|
|
||||||
[ -d "$HOME/Library/Application Support/ProtectedEmptyApp" ]
|
|
||||||
|
|
||||||
# Non-whitelisted directory should be removed
|
|
||||||
[ ! -d "$HOME/Library/Application Support/UnprotectedEmptyApp" ]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -103,22 +103,7 @@ EOF
|
|||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "clean_empty_library_items only cleans empty dirs" {
|
|
||||||
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF'
|
|
||||||
set -euo pipefail
|
|
||||||
source "$PROJECT_ROOT/lib/core/common.sh"
|
|
||||||
source "$PROJECT_ROOT/lib/clean/user.sh"
|
|
||||||
safe_clean() { echo "$2"; }
|
|
||||||
WHITELIST_PATTERNS=()
|
|
||||||
mkdir -p "$HOME/Library/EmptyDir"
|
|
||||||
touch "$HOME/Library/empty.txt"
|
|
||||||
clean_empty_library_items
|
|
||||||
EOF
|
|
||||||
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *"Empty Library folders"* ]]
|
|
||||||
[[ "$output" != *"Empty Library files"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "clean_browsers calls expected cache paths" {
|
@test "clean_browsers calls expected cache paths" {
|
||||||
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
|
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
|
||||||
|
|||||||
Reference in New Issue
Block a user