1
0
mirror of https://github.com/tw93/Mole.git synced 2026-03-22 18:30:08 +00:00

Refine update/uninstall UX and stabilize brew flows

This commit is contained in:
tw93
2026-03-05 17:46:05 +08:00
parent 9ee425766d
commit f91975e5be
5 changed files with 207 additions and 73 deletions

View File

@@ -257,10 +257,8 @@ batch_uninstall_applications() {
old_trap_term=$(trap -p TERM)
_cleanup_sudo_keepalive() {
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=""
if command -v stop_sudo_session >/dev/null 2>&1; then
stop_sudo_session
fi
}
@@ -378,9 +376,7 @@ batch_uninstall_applications() {
local size_display=$(bytes_to_human "$((total_estimated_size * 1024))")
echo ""
echo -e "${PURPLE_BOLD}Files to be removed:${NC}"
echo ""
echo -e "\n${PURPLE_BOLD}Files to be removed:${NC}"
# Warn if brew cask apps are present.
local has_brew_cask=false
@@ -390,10 +386,11 @@ batch_uninstall_applications() {
done
if [[ "$has_brew_cask" == "true" ]]; then
echo -e "${GRAY}${ICON_WARNING}${NC} ${YELLOW}Homebrew apps will be fully cleaned (--zap: removes configs & data)${NC}"
echo ""
echo -e "${GRAY}${ICON_WARNING} Homebrew apps will be fully cleaned, --zap removes configs and data${NC}"
fi
echo ""
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 encoded_diag_system <<< "$detail"
local app_size_display=$(bytes_to_human "$((total_kb * 1024))")
@@ -467,31 +464,21 @@ batch_uninstall_applications() {
# that user explicitly chose to uninstall. System-critical components remain protected.
export MOLE_UNINSTALL_MODE=1
# Request sudo if needed.
# Request sudo if needed for non-Homebrew removal operations.
# Note: Homebrew resets sudo timestamp at process startup, so pre-auth would
# cause duplicate password prompts in cask-only flows.
if [[ ${#sudo_apps[@]} -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then
if ! sudo -n true 2> /dev/null; then
if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then
if ! ensure_sudo_session "Admin required for system apps: ${sudo_apps[*]}"; then
echo ""
log_error "Admin access denied"
_restore_uninstall_traps
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 brew_apps_removed=0 # Track successful brew uninstalls for silent autoremove
local -a failed_items=()
local -a success_items=()
local current_index=0
@@ -786,32 +773,13 @@ batch_uninstall_applications() {
print_summary_block "$title" "${summary_details[@]}"
printf '\n'
# Auto-run brew autoremove if Homebrew casks were uninstalled
if [[ $brew_apps_removed -gt 0 ]]; then
if is_uninstall_dry_run; then
log_info "[DRY RUN] Would run brew autoremove"
else
# Show spinner while checking for orphaned dependencies
if [[ -t 1 ]]; then
start_inline_spinner "Checking brew dependencies..."
fi
local autoremove_output removed_count
# Add 30s timeout to prevent hanging on slow brew operations
# Use run_with_timeout for consistent cross-platform behavior (has shell fallback)
autoremove_output=$(run_with_timeout 30 bash -c '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
# Run brew autoremove silently in background to avoid interrupting UX.
if [[ $brew_apps_removed -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then
(
HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \
run_with_timeout 30 brew autoremove > /dev/null 2>&1 || true
) &
disown $! 2> /dev/null || true
fi
# Clean up Dock entries for uninstalled apps.
@@ -819,8 +787,11 @@ batch_uninstall_applications() {
if is_uninstall_dry_run; then
log_info "[DRY RUN] Would refresh LaunchServices and update Dock entries"
else
remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true
refresh_launch_services_after_uninstall 2> /dev/null || true
(
remove_apps_from_dock "${success_items[@]}" > /dev/null 2>&1 || true
refresh_launch_services_after_uninstall > /dev/null 2>&1 || true
) &
disown $! 2> /dev/null || true
fi
fi

View File

@@ -178,13 +178,6 @@ brew_uninstall_cask() {
debug_log "Attempting brew uninstall --cask --zap $cask_name"
# Ensure we have sudo access if needed, to prevent brew from hanging on password prompt
if [[ "${NONINTERACTIVE:-}" != "1" && -t 0 && -t 1 ]]; then
if ! sudo -n true 2> /dev/null; then
sudo -v
fi
fi
local uninstall_ok=false
local brew_exit=0

40
mole
View File

@@ -37,6 +37,31 @@ get_latest_version_from_github() {
echo "$version"
}
get_homebrew_latest_version() {
command -v brew > /dev/null 2>&1 || return 1
local line candidate=""
# Prefer local tap outdated info to avoid notifying before formula is available.
line=$(HOMEBREW_NO_AUTO_UPDATE=1 brew outdated --formula --verbose mole 2> /dev/null | head -1 || true)
if [[ "$line" == *"< "* ]]; then
candidate="${line##*< }"
candidate="${candidate%% *}"
fi
# Fallback for environments where outdated output is unavailable.
if [[ -z "$candidate" ]]; then
line=$(HOMEBREW_NO_AUTO_UPDATE=1 brew info mole 2> /dev/null | awk 'NR==1 { print; exit }' || true)
line="${line#==> }"
line="${line#*: }"
if [[ "$line" == stable* ]]; then
candidate=$(printf '%s\n' "$line" | awk '{print $2}')
fi
fi
[[ -n "$candidate" ]] && printf '%s\n' "$candidate"
}
# Install detection (Homebrew vs manual).
# Uses variable capture + string matching to avoid SIGPIPE under pipefail.
@@ -127,9 +152,16 @@ check_for_updates() {
if [[ -n "$latest" && "$VERSION" != "$latest" && "$(printf '%s\n' "$VERSION" "$latest" | sort -V | head -1)" == "$VERSION" ]]; then
if is_homebrew_install; then
printf "\nUpdate %s available on GitHub (Homebrew sync may be pending)\nRun %sbrew upgrade mole%s or wait for the tap to sync\n\n" "$latest" "$GREEN" "$NC" > "$msg_cache"
# For Homebrew, only notify if the brew tap has the new version available locally
local brew_latest
brew_latest=$(get_homebrew_latest_version || true)
if [[ -n "$brew_latest" && "$brew_latest" != "$VERSION" && "$(printf '%s\n' "$VERSION" "$brew_latest" | sort -V | head -1)" == "$VERSION" ]]; then
printf "\nUpdate %s available, run %smo update%s\n\n" "$brew_latest" "$GREEN" "$NC" > "$msg_cache"
else
printf "\nUpdate available: %s → %s, run %smo update%s\n\n" "$VERSION" "$latest" "$GREEN" "$NC" > "$msg_cache"
echo -n > "$msg_cache"
fi
else
printf "\nUpdate %s available, run %smo update%s\n\n" "$latest" "$GREEN" "$NC" > "$msg_cache"
fi
else
echo -n > "$msg_cache"
@@ -968,4 +1000,6 @@ main() {
esac
}
main "$@"
if [[ "${MOLE_SKIP_MAIN:-0}" != "1" ]]; then
main "$@"
fi

View File

@@ -123,7 +123,56 @@ EOF
[ "$status" -eq 0 ]
}
@test "batch_uninstall_applications tolerates brew autoremove timeout" {
@test "batch_uninstall_applications does not pre-auth sudo for brew-only casks" {
local app_bundle="$HOME/Applications/BrewPreAuth.app"
mkdir -p "$app_bundle"
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/uninstall/batch.sh"
start_inline_spinner() { :; }
stop_inline_spinner() { :; }
get_file_owner() { whoami; }
get_path_size_kb() { echo "100"; }
bytes_to_human() { echo "$1"; }
drain_pending_input() { :; }
print_summary_block() { :; }
remove_apps_from_dock() { :; }
force_kill_app() { return 0; }
run_with_timeout() { shift; "$@"; }
export -f run_with_timeout
ensure_sudo_session() {
echo "UNEXPECTED_ENSURE_SUDO:$*" >> "$HOME/order.log"
return 1
}
brew() {
echo "BREW_CALL:$*" >> "$HOME/order.log"
return 0
}
export -f brew
get_brew_cask_name() { echo "brew-preauth-cask"; return 0; }
export -f get_brew_cask_name
selected_apps=("0|$HOME/Applications/BrewPreAuth.app|BrewPreAuth|com.example.brewpreauth|0|Never")
files_cleaned=0
total_items=0
total_size_cleaned=0
printf '\n' | batch_uninstall_applications > /dev/null 2>&1
grep -q "BREW_CALL:uninstall --cask --zap brew-preauth-cask" "$HOME/order.log"
! grep -q "UNEXPECTED_ENSURE_SUDO:" "$HOME/order.log"
EOF
[ "$status" -eq 0 ]
}
@test "batch_uninstall_applications runs silent brew autoremove without UX noise" {
local app_bundle="$HOME/Applications/BrewTimeout.app"
mkdir -p "$app_bundle"
@@ -151,9 +200,6 @@ run_with_timeout() {
local duration="$1"
shift
echo "TIMEOUT_CALL:$duration:$*" >> "$HOME/timeout_calls.log"
if [[ "$duration" == "30" ]]; then
return 124
fi
"$@"
}
@@ -164,10 +210,51 @@ total_size_cleaned=0
printf '\n' | batch_uninstall_applications
cat "$HOME/timeout_calls.log"
sleep 0.2
if [[ -f "$HOME/timeout_calls.log" ]]; then
cat "$HOME/timeout_calls.log"
else
echo "NO_TIMEOUT_CALL"
fi
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"TIMEOUT_CALL:30:bash -c HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2>/dev/null"* ]]
[[ "$output" == *"LS_REFRESH"* ]]
[[ "$output" == *"TIMEOUT_CALL:30:brew autoremove"* ]]
[[ "$output" != *"Checking brew dependencies"* ]]
}
@test "brew_uninstall_cask does not trigger extra sudo pre-auth" {
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/uninstall/brew.sh"
debug_log() { :; }
get_path_size_kb() { echo "0"; }
run_with_timeout() { local _timeout="$1"; shift; "$@"; }
sudo() {
echo "UNEXPECTED_SUDO_CALL:$*"
return 1
}
brew() {
if [[ "${1:-}" == "uninstall" ]]; then
return 0
fi
if [[ "${1:-}" == "list" && "${2:-}" == "--cask" ]]; then
return 0
fi
return 0
}
export -f sudo brew
brew_uninstall_cask "mock-cask"
echo "DONE"
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"DONE"* ]]
[[ "$output" != *"UNEXPECTED_SUDO_CALL:"* ]]
}

View File

@@ -596,3 +596,52 @@ EOF
[[ "$output" == *"Homebrew installs follow stable releases."* ]]
[[ "$output" == *"mo update --nightly"* ]]
}
@test "get_homebrew_latest_version prefers brew outdated verbose target version" {
run bash --noprofile --norc <<'EOF'
set -euo pipefail
MOLE_SKIP_MAIN=1 source "$PROJECT_ROOT/mole"
brew() {
if [[ "${1:-}" == "outdated" ]]; then
echo "tw93/tap/mole (1.29.0) < 1.30.0"
return 0
fi
if [[ "${1:-}" == "info" ]]; then
echo "==> tw93/tap/mole: stable 9.9.9 (bottled)"
return 0
fi
return 0
}
export -f brew
get_homebrew_latest_version
EOF
[ "$status" -eq 0 ]
[[ "$output" == "1.30.0" ]]
}
@test "get_homebrew_latest_version parses brew info fallback with heading prefix" {
run bash --noprofile --norc <<'EOF'
set -euo pipefail
MOLE_SKIP_MAIN=1 source "$PROJECT_ROOT/mole"
brew() {
if [[ "${1:-}" == "outdated" ]]; then
return 0
fi
if [[ "${1:-}" == "info" ]]; then
echo "==> tw93/tap/mole: stable 1.31.1 (bottled), HEAD"
return 0
fi
return 0
}
export -f brew
get_homebrew_latest_version
EOF
[ "$status" -eq 0 ]
[[ "$output" == "1.31.1" ]]
}