1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-08 10:54:18 +00:00

feat: add Homebrew integration and optimize UI performance

- Add Homebrew cask detection and use 'brew uninstall --cask' for proper cleanup
  - Add real-time progress feedback during uninstallation
  - Optimize scroll performance by only redrawing visible items
  - Replace Python-based Dock removal with PlistBuddy for better compatibility
  - Add comprehensive tests for Homebrew functionality

  Fixes #306
This commit is contained in:
Tw93
2026-01-13 10:44:48 +08:00
parent 4d210913d8
commit 6b594c7d69
4 changed files with 411 additions and 149 deletions

View File

@@ -99,91 +99,119 @@ update_via_homebrew() {
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_apps_from_dock() {
if [[ $# -eq 0 ]]; then
return 0
fi
local plist="$HOME/Library/Preferences/com.apple.dock.plist"
[[ -f "$plist" ]] || return 0
local -a targets=()
for arg in "$@"; do
[[ -n "$arg" ]] && targets+=("$arg")
done
if ! command -v python3 > /dev/null 2>&1; then
if [[ ${#targets[@]} -eq 0 ]]; then
return 0
fi
# Prune dock entries using Python helper
python3 - "$@" << 'PY' 2> /dev/null || return 0
import os
import plistlib
import subprocess
import sys
import urllib.parse
# Use pure shell (PlistBuddy) to remove items from Dock
# This avoids dependencies on Python 3 or osascript (AppleScript)
local plist="$HOME/Library/Preferences/com.apple.dock.plist"
[[ -f "$plist" ]] || return 0
plist_path = os.path.expanduser('~/Library/Preferences/com.apple.dock.plist')
if not os.path.exists(plist_path):
sys.exit(0)
command -v PlistBuddy > /dev/null 2>&1 || return 0
def normalise(path):
if not path:
return ''
return os.path.normpath(os.path.realpath(path.rstrip('/')))
local changed=false
for target in "${targets[@]}"; do
local app_path="$target"
local app_name
app_name=$(basename "$app_path" .app)
targets = {normalise(arg) for arg in sys.argv[1:] if arg}
targets = {t for t in targets if t}
if not targets:
sys.exit(0)
# Normalize path for comparison - realpath might fail if app is already deleted
local full_path
full_path=$(cd "$(dirname "$app_path")" 2> /dev/null && pwd || echo "")
[[ -n "$full_path" ]] && full_path="$full_path/$(basename "$app_path")"
with open(plist_path, 'rb') as fh:
try:
data = plistlib.load(fh)
except Exception:
sys.exit(0)
# Find the index of the app in persistent-apps
local i=0
while true; do
local label
label=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-label" "$plist" 2> /dev/null || echo "")
[[ -z "$label" ]] && break
apps = data.get('persistent-apps')
if not isinstance(apps, list):
sys.exit(0)
local url
url=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-data:_CFURLString" "$plist" 2> /dev/null || echo "")
changed = False
filtered = []
for item in apps:
try:
url = item['tile-data']['file-data']['_CFURLString']
except (KeyError, TypeError):
filtered.append(item)
continue
# Match by label or by path (parsing the CFURLString which is usually a file:// URL)
if [[ "$label" == "$app_name" ]] || [[ "$url" == *"$app_name.app"* ]]; then
# Double check path if possible to avoid false positives for similarly named apps
if [[ -n "$full_path" && "$url" == *"$full_path"* ]] || [[ "$label" == "$app_name" ]]; then
if /usr/libexec/PlistBuddy -c "Delete :persistent-apps:$i" "$plist" 2> /dev/null; then
changed=true
# After deletion, current index i now points to the next item
continue
fi
fi
fi
((i++))
done
done
if not isinstance(url, str):
filtered.append(item)
continue
parsed = urllib.parse.urlparse(url)
path = urllib.parse.unquote(parsed.path or '')
if not path:
filtered.append(item)
continue
candidate = normalise(path)
if any(candidate == t or candidate.startswith(t + os.sep) for t in targets):
changed = True
continue
filtered.append(item)
if not changed:
sys.exit(0)
data['persistent-apps'] = filtered
with open(plist_path, 'wb') as fh:
try:
plistlib.dump(data, fh, fmt=plistlib.FMT_BINARY)
except Exception:
plistlib.dump(data, fh)
# Restart Dock to apply changes
try:
subprocess.run(['killall', 'Dock'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
except Exception:
pass
PY
if [[ "$changed" == "true" ]]; then
# Restart Dock to apply changes from the plist
killall Dock 2> /dev/null || true
fi
}

View File

@@ -632,10 +632,29 @@ paginated_multi_select() {
prev_cursor_pos=$cursor_pos
continue # Skip full redraw
elif [[ $top_index -gt 0 ]]; then
# Scroll up - redraw visible items only
((top_index--))
# Redraw all visible items (faster than full screen redraw)
local start_idx=$top_index
local end_idx=$((top_index + items_per_page - 1))
local visible_total=${#view_indices[@]}
[[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1))
for ((i = start_idx; i <= end_idx; i++)); do
local row=$((i - start_idx + 3)) # +3 for header
printf "\033[%d;1H" "$row" >&2
local is_current=false
[[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true
render_item $((i - start_idx)) $is_current
done
# Move cursor to footer
printf "\033[%d;1H" "$((items_per_page + 4))" >&2
prev_cursor_pos=$cursor_pos
prev_top_index=$top_index
need_full_redraw=true # Scrolling requires full redraw
continue
fi
;;
"DOWN")
@@ -670,15 +689,34 @@ paginated_multi_select() {
prev_cursor_pos=$cursor_pos
continue # Skip full redraw
elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then
# Scroll down - redraw visible items only
((top_index++))
visible_count=$((${#view_indices[@]} - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -ge $visible_count ]]; then
cursor_pos=$((visible_count - 1))
fi
# Redraw all visible items (faster than full screen redraw)
local start_idx=$top_index
local end_idx=$((top_index + items_per_page - 1))
local visible_total=${#view_indices[@]}
[[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1))
for ((i = start_idx; i <= end_idx; i++)); do
local row=$((i - start_idx + 3)) # +3 for header
printf "\033[%d;1H" "$row" >&2
local is_current=false
[[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true
render_item $((i - start_idx)) $is_current
done
# Move cursor to footer
printf "\033[%d;1H" "$((items_per_page + 4))" >&2
prev_cursor_pos=$cursor_pos
prev_top_index=$top_index
need_full_redraw=true # Scrolling requires full redraw
continue
fi
fi
fi

View File

@@ -146,49 +146,72 @@ batch_uninstall_applications() {
running_apps+=("$app_name")
fi
# Sudo needed if bundle owner/dir is not writable or system files exist.
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
# Check if it's a Homebrew cask
local cask_name=""
cask_name=$(get_brew_cask_name "$app_path" || echo "")
local is_brew_cask="false"
[[ -n "$cask_name" ]] && is_brew_cask="true"
# For Homebrew casks, skip detailed file scanning since brew handles it
if [[ "$is_brew_cask" == "true" ]]; then
local app_size_kb=$(get_path_size_kb "$app_path")
local total_kb=$app_size_kb
((total_estimated_size += total_kb))
# Homebrew may need sudo for system-wide installations
local needs_sudo=false
if [[ "$app_path" == "/Applications/"* ]]; then
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
# 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")
done
if [[ -t 1 ]]; then stop_inline_spinner; fi
@@ -214,41 +237,49 @@ batch_uninstall_applications() {
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 <<< "$detail"
local related_files=$(decode_file_list "$encoded_files" "$app_name")
local system_files=$(decode_file_list "$encoded_system_files" "$app_name")
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))")
echo -e "${BLUE}${ICON_CONFIRM}${NC} ${app_name} ${GRAY}(${app_size_display})${NC}"
echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${app_path/$HOME/~}"
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 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/~}"
# For Homebrew apps, [Brew] tag is enough indication
# For non-Homebrew apps, show detailed file list
if [[ "$is_brew_cask" != "true" ]]; then
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
((file_count++))
fi
done <<< "$related_files"
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"
# 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
((sys_file_count++))
fi
done <<< "$system_files"
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}"
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
@@ -275,7 +306,7 @@ batch_uninstall_applications() {
return 0
;;
"" | $'\n' | $'\r' | y | Y)
printf "\r\033[K" # Clear the prompt line
echo "" # Move to next line
;;
*)
echo ""
@@ -305,19 +336,29 @@ batch_uninstall_applications() {
sudo_keepalive_pid=$!
fi
if [[ -t 1 ]]; then start_inline_spinner "Uninstalling apps..."; fi
# Perform uninstallations (silent mode, show results at end).
if [[ -t 1 ]]; then stop_inline_spinner; fi
# Perform uninstallations with per-app progress feedback
local success_count=0 failed_count=0
local -a failed_items=()
local -a success_items=()
local current_index=0
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 <<< "$detail"
((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"
@@ -329,7 +370,19 @@ batch_uninstall_applications() {
# Remove the application only if not running.
if [[ -z "$reason" ]]; then
if [[ "$needs_sudo" == true ]]; then
if [[ "$is_brew_cask" == "true" && -n "$cask_name" ]]; then
# Use brew uninstall --cask with progress indicator
local brew_output_file=$(mktemp)
if ! run_with_timeout 120 brew uninstall --cask "$cask_name" > "$brew_output_file" 2>&1; then
# 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
rm -f "$brew_output_file"
elif [[ "$needs_sudo" == true ]]; then
if ! safe_sudo_remove "$app_path"; then
local app_owner=$(get_file_owner "$app_path")
local current_user=$(whoami)
@@ -361,12 +414,32 @@ batch_uninstall_applications() {
fi
fi
# Stop spinner and show success
if [[ -t 1 ]]; then
stop_inline_spinner
if [[ ${#app_details[@]} -gt 1 ]]; then
echo -e "\r\033[K${GREEN}${NC} [$current_index/${#app_details[@]}] ${app_name}"
else
echo -e "\r\033[K${GREEN}${NC} ${app_name}"
fi
fi
((total_size_freed += total_kb))
((success_count++))
((files_cleaned++))
((total_items++))
success_items+=("$app_name")
else
# Stop spinner and show failure
if [[ -t 1 ]]; then
stop_inline_spinner
if [[ ${#app_details[@]} -gt 1 ]]; then
echo -e "\r\033[K${RED}${NC} [$current_index/${#app_details[@]}] ${app_name} ${GRAY}($reason)${NC}"
else
echo -e "\r\033[K${RED}${NC} ${app_name} failed: $reason"
fi
fi
((failed_count++))
failed_items+=("$app_name:$reason")
fi
@@ -454,6 +527,7 @@ batch_uninstall_applications() {
title="Uninstall incomplete"
fi
echo ""
print_summary_block "$title" "${summary_details[@]}"
printf '\n'