2025-09-25 20:22:51 +08:00
|
|
|
#!/bin/bash
|
|
|
|
|
|
2025-10-08 18:01:46 +08:00
|
|
|
set -euo pipefail
|
|
|
|
|
|
2025-12-31 16:23:31 +08:00
|
|
|
# Ensure common.sh is loaded.
|
2026-01-14 08:55:41 -05:00
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
2025-12-01 16:58:35 +08:00
|
|
|
[[ -z "${MOLE_COMMON_LOADED:-}" ]] && source "$SCRIPT_DIR/lib/core/common.sh"
|
2025-10-08 18:01:46 +08:00
|
|
|
|
2026-01-14 08:55:41 -05:00
|
|
|
# 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"
|
|
|
|
|
|
2025-12-31 16:23:31 +08:00
|
|
|
# Batch uninstall with a single confirmation.
|
2025-11-14 11:38:25 +08:00
|
|
|
|
Add dry-run support across destructive commands (#516)
* chore: update contributors [skip ci]
* Add dry-run support across destructive commands
Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped).
* test(purge): keep dev-compatible purge coverage
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tw93 <hitw93@gmail.com>
2026-03-01 20:03:22 +08:00
|
|
|
get_lsregister_path() {
|
|
|
|
|
echo "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
is_uninstall_dry_run() {
|
|
|
|
|
[[ "${MOLE_DRY_RUN:-0}" == "1" ]]
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 09:09:11 +08:00
|
|
|
# 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
|
2026-01-17 01:53:40 +00:00
|
|
|
*/.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
|
|
|
|
|
;;
|
2026-01-17 09:09:11 +08:00
|
|
|
esac
|
2026-01-17 01:53:40 +00:00
|
|
|
done <<< "$files"
|
2026-01-17 09:09:11 +08:00
|
|
|
|
|
|
|
|
return 1 # Not found
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-31 16:23:31 +08:00
|
|
|
# Decode and validate base64 file list (safe for set -e).
|
2025-11-14 11:38:25 +08:00
|
|
|
decode_file_list() {
|
|
|
|
|
local encoded="$1"
|
|
|
|
|
local app_name="$2"
|
|
|
|
|
local decoded
|
|
|
|
|
|
2025-12-31 16:23:31 +08:00
|
|
|
# macOS uses -D, GNU uses -d. Always return 0 for set -e safety.
|
2026-01-17 01:53:40 +00:00
|
|
|
if ! decoded=$(printf '%s' "$encoded" | base64 -D 2> /dev/null); then
|
|
|
|
|
if ! decoded=$(printf '%s' "$encoded" | base64 -d 2> /dev/null); then
|
2025-12-25 11:24:12 +08:00
|
|
|
log_error "Failed to decode file list for $app_name" >&2
|
|
|
|
|
echo ""
|
2025-12-25 03:27:51 +00:00
|
|
|
return 0 # Return success with empty string
|
2025-12-25 11:24:12 +08:00
|
|
|
fi
|
2025-11-14 11:38:25 +08:00
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [[ "$decoded" =~ $'\0' ]]; then
|
2025-12-25 11:24:12 +08:00
|
|
|
log_warning "File list for $app_name contains null bytes, rejecting" >&2
|
2025-11-14 11:38:25 +08:00
|
|
|
echo ""
|
2025-12-25 03:27:51 +00:00
|
|
|
return 0 # Return success with empty string
|
2025-11-14 11:38:25 +08:00
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
while IFS= read -r line; do
|
|
|
|
|
if [[ -n "$line" && ! "$line" =~ ^/ ]]; then
|
2025-12-25 11:24:12 +08:00
|
|
|
log_warning "Invalid path in file list for $app_name: $line" >&2
|
2025-11-14 11:38:25 +08:00
|
|
|
echo ""
|
2025-12-25 03:27:51 +00:00
|
|
|
return 0 # Return success with empty string
|
2025-11-14 11:38:25 +08:00
|
|
|
fi
|
2026-01-17 01:53:40 +00:00
|
|
|
done <<< "$decoded"
|
2025-11-14 11:38:25 +08:00
|
|
|
|
|
|
|
|
echo "$decoded"
|
|
|
|
|
return 0
|
|
|
|
|
}
|
2025-12-31 16:23:31 +08:00
|
|
|
# Note: find_app_files() and calculate_total_size() are in lib/core/common.sh.
|
2025-09-25 20:22:51 +08:00
|
|
|
|
2025-12-31 16:23:31 +08:00
|
|
|
# Stop Launch Agents/Daemons for an app.
|
2026-01-26 20:27:46 +08:00
|
|
|
# Security: bundle_id is validated to be reverse-DNS format before use in find patterns
|
2025-12-02 10:58:40 +08:00
|
|
|
stop_launch_services() {
|
|
|
|
|
local bundle_id="$1"
|
|
|
|
|
local has_system_files="${2:-false}"
|
|
|
|
|
|
Add dry-run support across destructive commands (#516)
* chore: update contributors [skip ci]
* Add dry-run support across destructive commands
Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped).
* test(purge): keep dev-compatible purge coverage
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tw93 <hitw93@gmail.com>
2026-03-01 20:03:22 +08:00
|
|
|
if is_uninstall_dry_run; then
|
|
|
|
|
debug_log "[DRY RUN] Would unload launch services for bundle: $bundle_id"
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
|
2025-12-25 11:24:12 +08:00
|
|
|
[[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0
|
|
|
|
|
|
2026-01-26 20:27:46 +08:00
|
|
|
# Validate bundle_id format: must be reverse-DNS style (e.g., com.example.app)
|
|
|
|
|
# This prevents glob injection attacks if bundle_id contains special characters
|
|
|
|
|
if [[ ! "$bundle_id" =~ ^[a-zA-Z0-9][-a-zA-Z0-9]*(\.[a-zA-Z0-9][-a-zA-Z0-9]*)+$ ]]; then
|
|
|
|
|
debug_log "Invalid bundle_id format for LaunchAgent search: $bundle_id"
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
|
2025-12-25 11:24:12 +08:00
|
|
|
if [[ -d ~/Library/LaunchAgents ]]; then
|
|
|
|
|
while IFS= read -r -d '' plist; do
|
2026-01-17 01:53:40 +00:00
|
|
|
launchctl unload "$plist" 2> /dev/null || true
|
|
|
|
|
done < <(find ~/Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null)
|
2025-12-25 11:24:12 +08:00
|
|
|
fi
|
2025-12-02 10:58:40 +08:00
|
|
|
|
|
|
|
|
if [[ "$has_system_files" == "true" ]]; then
|
2025-12-25 11:24:12 +08:00
|
|
|
if [[ -d /Library/LaunchAgents ]]; then
|
|
|
|
|
while IFS= read -r -d '' plist; do
|
2026-01-17 01:53:40 +00:00
|
|
|
sudo launchctl unload "$plist" 2> /dev/null || true
|
|
|
|
|
done < <(find /Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null)
|
2025-12-25 11:24:12 +08:00
|
|
|
fi
|
|
|
|
|
if [[ -d /Library/LaunchDaemons ]]; then
|
|
|
|
|
while IFS= read -r -d '' plist; do
|
2026-01-17 01:53:40 +00:00
|
|
|
sudo launchctl unload "$plist" 2> /dev/null || true
|
|
|
|
|
done < <(find /Library/LaunchDaemons -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null)
|
2025-12-25 11:24:12 +08:00
|
|
|
fi
|
2025-12-02 10:58:40 +08:00
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 14:26:33 +08:00
|
|
|
# Unregister app bundle from LaunchServices before deleting files.
|
|
|
|
|
# This helps remove stale app entries from Spotlight's app results list.
|
|
|
|
|
unregister_app_bundle() {
|
|
|
|
|
local app_path="$1"
|
|
|
|
|
|
|
|
|
|
[[ -n "$app_path" && -e "$app_path" ]] || return 0
|
|
|
|
|
[[ "$app_path" == *.app ]] || return 0
|
|
|
|
|
|
|
|
|
|
local lsregister
|
|
|
|
|
lsregister=$(get_lsregister_path)
|
|
|
|
|
[[ -x "$lsregister" ]] || return 0
|
|
|
|
|
|
|
|
|
|
[[ "${MOLE_DRY_RUN:-0}" == "1" ]] && return 0
|
|
|
|
|
|
|
|
|
|
set +e
|
|
|
|
|
"$lsregister" -u "$app_path" > /dev/null 2>&1
|
|
|
|
|
set -e
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Compact and rebuild LaunchServices after uninstall batch to clear stale app metadata.
|
|
|
|
|
refresh_launch_services_after_uninstall() {
|
|
|
|
|
local lsregister
|
|
|
|
|
lsregister=$(get_lsregister_path)
|
|
|
|
|
[[ -x "$lsregister" ]] || return 0
|
|
|
|
|
|
|
|
|
|
[[ "${MOLE_DRY_RUN:-0}" == "1" ]] && return 0
|
|
|
|
|
|
|
|
|
|
local success=0
|
|
|
|
|
set +e
|
2026-03-05 12:00:07 +08:00
|
|
|
# Add 10s timeout to prevent hanging (gc is usually fast)
|
|
|
|
|
# run_with_timeout falls back to shell implementation if timeout command unavailable
|
|
|
|
|
run_with_timeout 10 "$lsregister" -gc > /dev/null 2>&1 || true
|
|
|
|
|
# Add 15s timeout for rebuild (can be slow on some systems)
|
|
|
|
|
run_with_timeout 15 "$lsregister" -r -f -domain local -domain user -domain system > /dev/null 2>&1
|
2026-02-24 14:26:33 +08:00
|
|
|
success=$?
|
2026-03-05 12:00:07 +08:00
|
|
|
# 124 = timeout exit code (from run_with_timeout or timeout command)
|
|
|
|
|
if [[ $success -eq 124 ]]; then
|
|
|
|
|
debug_log "LaunchServices rebuild timed out, trying lighter version"
|
|
|
|
|
run_with_timeout 10 "$lsregister" -r -f -domain local -domain user > /dev/null 2>&1
|
|
|
|
|
success=$?
|
|
|
|
|
elif [[ $success -ne 0 ]]; then
|
|
|
|
|
run_with_timeout 10 "$lsregister" -r -f -domain local -domain user > /dev/null 2>&1
|
2026-02-24 14:26:33 +08:00
|
|
|
success=$?
|
|
|
|
|
fi
|
|
|
|
|
set -e
|
|
|
|
|
|
2026-03-05 12:00:07 +08:00
|
|
|
[[ $success -eq 0 || $success -eq 124 ]]
|
2026-02-24 14:26:33 +08:00
|
|
|
}
|
|
|
|
|
|
2026-01-15 11:39:33 +08:00
|
|
|
# Remove macOS Login Items for an app
|
|
|
|
|
remove_login_item() {
|
|
|
|
|
local app_name="$1"
|
|
|
|
|
local bundle_id="$2"
|
|
|
|
|
|
Add dry-run support across destructive commands (#516)
* chore: update contributors [skip ci]
* Add dry-run support across destructive commands
Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped).
* test(purge): keep dev-compatible purge coverage
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tw93 <hitw93@gmail.com>
2026-03-01 20:03:22 +08:00
|
|
|
if is_uninstall_dry_run; then
|
|
|
|
|
debug_log "[DRY RUN] Would remove login item: ${app_name:-$bundle_id}"
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
|
2026-01-15 11:39:33 +08:00
|
|
|
# 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
|
2026-01-15 15:13:51 +08:00
|
|
|
# Escape double quotes and backslashes for AppleScript
|
|
|
|
|
local escaped_name="${clean_name//\\/\\\\}"
|
|
|
|
|
escaped_name="${escaped_name//\"/\\\"}"
|
|
|
|
|
|
2026-01-17 01:53:40 +00:00
|
|
|
osascript <<- EOF > /dev/null 2>&1 || true
|
2026-01-15 11:39:33 +08:00
|
|
|
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
|
2026-01-15 15:13:51 +08:00
|
|
|
if itemName is "$escaped_name" then
|
2026-01-15 11:39:33 +08:00
|
|
|
delete login item i
|
|
|
|
|
end if
|
|
|
|
|
end try
|
|
|
|
|
end repeat
|
|
|
|
|
end try
|
|
|
|
|
end tell
|
|
|
|
|
EOF
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-31 16:23:31 +08:00
|
|
|
# Remove files (handles symlinks, optional sudo).
|
2026-01-26 20:27:46 +08:00
|
|
|
# Security: All paths pass validate_path_for_deletion() before any deletion.
|
2025-12-02 10:58:40 +08:00
|
|
|
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
|
|
|
|
|
|
fix(uninstall): enhance receipt file processing safety and prevent system file deletion
CRITICAL SECURITY FIX
Enhanced the receipt file parsing in uninstall operations to prevent
accidental deletion of critical system files while maintaining deep
cleanup capabilities.
Changes:
- Tightened whitelist in find_app_receipt_files() to exclude /Users/*,
/usr/*, and /opt/* broad patterns
- Added explicit blacklist for /private/* with safe exceptions for
logs, temp files, and diagnostic data
- Integrated should_protect_path() check for additional protection
- Added file deduplication with sort -u to prevent duplicate deletions
- Removed dry-run feature from batch uninstall (unused entry point)
Path Protection:
✅ Blocked: /etc/passwd, /var/db/*, /private/etc/*, all system binaries
✅ Allowed: /Applications/*, specific /Library/* subdirs, safe /private/* paths
✅ Additional: Keychain files, system preferences via should_protect_path()
This fixes a critical security issue where parsing .bom receipt files
could result in deletion of system files like /etc/passwd and /var/db/*,
leading to system corruption and data loss.
Affects: V1.12.14 and later versions
Testing: Validated against critical system paths, all blocked correctly
2026-01-15 21:01:11 +08:00
|
|
|
if ! validate_path_for_deletion "$file"; then
|
|
|
|
|
continue
|
|
|
|
|
fi
|
|
|
|
|
|
2025-12-02 10:58:40 +08:00
|
|
|
if [[ -L "$file" ]]; then
|
2026-02-02 17:05:42 +08:00
|
|
|
safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true
|
2025-12-02 10:58:40 +08:00
|
|
|
else
|
|
|
|
|
if [[ "$use_sudo" == "true" ]]; then
|
Add dry-run support across destructive commands (#516)
* chore: update contributors [skip ci]
* Add dry-run support across destructive commands
Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped).
* test(purge): keep dev-compatible purge coverage
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tw93 <hitw93@gmail.com>
2026-03-01 20:03:22 +08:00
|
|
|
if is_uninstall_dry_run; then
|
|
|
|
|
debug_log "[DRY RUN] Would sudo remove: $file"
|
|
|
|
|
((++count))
|
|
|
|
|
else
|
|
|
|
|
safe_sudo_remove "$file" && ((++count)) || true
|
|
|
|
|
fi
|
2025-12-02 10:58:40 +08:00
|
|
|
else
|
2026-01-14 08:55:41 -05:00
|
|
|
safe_remove "$file" true && ((++count)) || true
|
2025-12-02 10:58:40 +08:00
|
|
|
fi
|
|
|
|
|
fi
|
2026-01-17 01:53:40 +00:00
|
|
|
done <<< "$file_list"
|
2025-12-02 10:58:40 +08:00
|
|
|
|
|
|
|
|
echo "$count"
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-31 16:23:31 +08:00
|
|
|
# Batch uninstall with single confirmation.
|
2025-09-25 20:22:51 +08:00
|
|
|
batch_uninstall_applications() {
|
|
|
|
|
local total_size_freed=0
|
|
|
|
|
|
2025-12-02 17:02:14 +08:00
|
|
|
# shellcheck disable=SC2154
|
2025-09-25 20:22:51 +08:00
|
|
|
if [[ ${#selected_apps[@]} -eq 0 ]]; then
|
|
|
|
|
log_warning "No applications selected for uninstallation"
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
|
2026-01-22 17:45:53 +08:00
|
|
|
local old_trap_int old_trap_term
|
|
|
|
|
old_trap_int=$(trap -p INT)
|
|
|
|
|
old_trap_term=$(trap -p TERM)
|
|
|
|
|
|
2026-02-02 17:05:42 +08:00
|
|
|
_cleanup_sudo_keepalive() {
|
2026-03-05 09:51:52 +00:00
|
|
|
if command -v stop_sudo_session > /dev/null 2>&1; then
|
2026-03-05 17:46:05 +08:00
|
|
|
stop_sudo_session
|
2026-02-02 17:05:42 +08:00
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 17:45:53 +08:00
|
|
|
_restore_uninstall_traps() {
|
2026-02-02 17:05:42 +08:00
|
|
|
_cleanup_sudo_keepalive
|
2026-01-22 17:45:53 +08:00
|
|
|
if [[ -n "$old_trap_int" ]]; then
|
|
|
|
|
eval "$old_trap_int"
|
|
|
|
|
else
|
|
|
|
|
trap - INT
|
|
|
|
|
fi
|
|
|
|
|
if [[ -n "$old_trap_term" ]]; then
|
|
|
|
|
eval "$old_trap_term"
|
|
|
|
|
else
|
|
|
|
|
trap - TERM
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 17:05:42 +08:00
|
|
|
# Trap to clean up spinner, sudo keepalive, and uninstall mode on interrupt
|
|
|
|
|
trap 'stop_inline_spinner 2>/dev/null; _cleanup_sudo_keepalive; unset MOLE_UNINSTALL_MODE; echo ""; _restore_uninstall_traps; return 130' INT TERM
|
2026-01-22 17:45:53 +08:00
|
|
|
|
2025-12-31 16:23:31 +08:00
|
|
|
# Pre-scan: running apps, sudo needs, size.
|
2025-09-25 20:22:51 +08:00
|
|
|
local -a running_apps=()
|
2025-10-03 11:38:54 +08:00
|
|
|
local -a sudo_apps=()
|
2025-09-25 20:22:51 +08:00
|
|
|
local total_estimated_size=0
|
|
|
|
|
local -a app_details=()
|
|
|
|
|
|
2026-01-19 16:53:51 +08:00
|
|
|
# Cache current user outside loop
|
|
|
|
|
local current_user=$(whoami)
|
|
|
|
|
|
2025-12-05 14:21:18 +08:00
|
|
|
if [[ -t 1 ]]; then start_inline_spinner "Scanning files..."; fi
|
2025-09-25 20:22:51 +08:00
|
|
|
for selected_app in "${selected_apps[@]}"; do
|
2025-10-08 18:01:46 +08:00
|
|
|
[[ -z "$selected_app" ]] && continue
|
2026-01-17 01:53:40 +00:00
|
|
|
IFS='|' read -r _ app_path app_name bundle_id _ _ <<< "$selected_app"
|
2025-09-25 20:22:51 +08:00
|
|
|
|
2026-01-19 16:53:51 +08:00
|
|
|
# Check running app by bundle executable if available
|
2025-12-04 15:06:45 +08:00
|
|
|
local exec_name=""
|
2026-01-19 16:53:51 +08:00
|
|
|
local info_plist="$app_path/Contents/Info.plist"
|
|
|
|
|
if [[ -e "$info_plist" ]]; then
|
2026-01-19 08:54:45 +00:00
|
|
|
exec_name=$(defaults read "$info_plist" CFBundleExecutable 2> /dev/null || echo "")
|
2025-12-04 15:06:45 +08:00
|
|
|
fi
|
2026-01-19 08:54:45 +00:00
|
|
|
if pgrep -qx "${exec_name:-$app_name}" 2> /dev/null; then
|
2025-09-25 20:22:51 +08:00
|
|
|
running_apps+=("$app_name")
|
|
|
|
|
fi
|
|
|
|
|
|
2026-01-19 16:53:51 +08:00
|
|
|
local cask_name="" is_brew_cask="false"
|
2026-01-19 08:54:45 +00:00
|
|
|
local resolved_path=$(readlink "$app_path" 2> /dev/null || echo "")
|
2026-01-19 16:53:51 +08:00
|
|
|
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"
|
2026-01-20 10:26:13 +08:00
|
|
|
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
|
2026-01-19 16:53:51 +08:00
|
|
|
fi
|
2026-01-13 10:44:48 +08:00
|
|
|
|
2026-01-19 16:53:51 +08:00
|
|
|
# Check if sudo is needed
|
2026-01-14 08:55:41 -05:00
|
|
|
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
|
2025-12-02 10:58:40 +08:00
|
|
|
|
2026-02-02 17:05:42 +08:00
|
|
|
local app_size_kb=$(get_path_size_kb "$app_path" || echo "0")
|
|
|
|
|
local related_files=$(find_app_files "$bundle_id" "$app_name" || true)
|
2026-02-11 15:35:45 +09:00
|
|
|
local diag_user
|
|
|
|
|
diag_user=$(get_diagnostic_report_paths_for_app "$app_path" "$app_name" "$HOME/Library/Logs/DiagnosticReports" || true)
|
|
|
|
|
[[ -n "$diag_user" ]] && related_files=$(
|
|
|
|
|
[[ -n "$related_files" ]] && echo "$related_files"
|
|
|
|
|
echo "$diag_user"
|
|
|
|
|
)
|
2026-02-02 17:05:42 +08:00
|
|
|
local related_size_kb=$(calculate_total_size "$related_files" || echo "0")
|
2026-01-14 08:55:41 -05:00
|
|
|
# system_files is a newline-separated string, not an array.
|
|
|
|
|
# shellcheck disable=SC2178,SC2128
|
2026-02-02 17:05:42 +08:00
|
|
|
local system_files=$(find_app_system_files "$bundle_id" "$app_name" || true)
|
2026-02-11 15:35:45 +09:00
|
|
|
local diag_system
|
|
|
|
|
diag_system=$(get_diagnostic_report_paths_for_app "$app_path" "$app_name" "/Library/Logs/DiagnosticReports" || true)
|
2026-01-14 08:55:41 -05:00
|
|
|
# shellcheck disable=SC2128
|
2026-02-02 17:05:42 +08:00
|
|
|
local system_size_kb=$(calculate_total_size "$system_files" || echo "0")
|
2026-02-11 15:35:45 +09:00
|
|
|
local diag_system_size_kb=$(calculate_total_size "$diag_system" || echo "0")
|
|
|
|
|
local total_kb=$((app_size_kb + related_size_kb + system_size_kb + diag_system_size_kb))
|
2026-02-28 11:10:18 +08:00
|
|
|
total_estimated_size=$((total_estimated_size + total_kb))
|
2026-01-14 08:55:41 -05:00
|
|
|
|
|
|
|
|
# shellcheck disable=SC2128
|
2026-02-11 15:35:45 +09:00
|
|
|
if [[ -n "$system_files" || -n "$diag_system" ]]; then
|
2026-01-14 08:55:41 -05:00
|
|
|
needs_sudo=true
|
|
|
|
|
fi
|
2025-12-25 11:24:12 +08:00
|
|
|
|
2026-01-14 08:55:41 -05:00
|
|
|
if [[ "$needs_sudo" == "true" ]]; then
|
|
|
|
|
sudo_apps+=("$app_name")
|
|
|
|
|
fi
|
2026-01-13 10:44:48 +08:00
|
|
|
|
2026-01-14 08:55:41 -05:00
|
|
|
# Check for sensitive user data once.
|
|
|
|
|
local has_sensitive_data="false"
|
2026-02-02 17:05:42 +08:00
|
|
|
if has_sensitive_data "$related_files" 2> /dev/null; then
|
2026-01-14 08:55:41 -05:00
|
|
|
has_sensitive_data="true"
|
2026-01-13 10:44:48 +08:00
|
|
|
fi
|
2026-01-14 08:55:41 -05:00
|
|
|
|
|
|
|
|
# Store details for later use (base64 keeps lists on one line).
|
|
|
|
|
local encoded_files
|
2026-02-02 17:05:42 +08:00
|
|
|
encoded_files=$(printf '%s' "$related_files" | base64 | tr -d '\n' || echo "")
|
2026-01-14 08:55:41 -05:00
|
|
|
local encoded_system_files
|
2026-02-02 17:05:42 +08:00
|
|
|
encoded_system_files=$(printf '%s' "$system_files" | base64 | tr -d '\n' || echo "")
|
2026-02-11 15:35:45 +09:00
|
|
|
local encoded_diag_system
|
|
|
|
|
encoded_diag_system=$(printf '%s' "$diag_system" | base64 | tr -d '\n' || echo "")
|
|
|
|
|
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|$encoded_diag_system")
|
2025-09-25 20:22:51 +08:00
|
|
|
done
|
2025-12-05 14:21:18 +08:00
|
|
|
if [[ -t 1 ]]; then stop_inline_spinner; fi
|
2025-09-25 20:22:51 +08:00
|
|
|
|
2025-10-08 18:01:46 +08:00
|
|
|
local size_display=$(bytes_to_human "$((total_estimated_size * 1024))")
|
2025-09-25 20:22:51 +08:00
|
|
|
|
2026-03-05 17:46:05 +08:00
|
|
|
echo -e "\n${PURPLE_BOLD}Files to be removed:${NC}"
|
2025-12-25 11:24:12 +08:00
|
|
|
|
2026-02-03 17:36:15 +08:00
|
|
|
# Warn if brew cask apps are present.
|
|
|
|
|
local has_brew_cask=false
|
2025-10-11 11:40:01 +08:00
|
|
|
for detail in "${app_details[@]}"; do
|
2026-02-03 17:36:15 +08:00
|
|
|
IFS='|' read -r _ _ _ _ _ _ _ _ is_brew_cask_flag _ <<< "$detail"
|
|
|
|
|
[[ "$is_brew_cask_flag" == "true" ]] && has_brew_cask=true
|
2025-12-25 11:24:12 +08:00
|
|
|
done
|
|
|
|
|
|
2026-02-03 17:36:15 +08:00
|
|
|
if [[ "$has_brew_cask" == "true" ]]; then
|
2026-03-05 17:46:05 +08:00
|
|
|
echo -e "${GRAY}${ICON_WARNING} Homebrew apps will be fully cleaned, --zap removes configs and data${NC}"
|
2025-12-25 11:24:12 +08:00
|
|
|
fi
|
|
|
|
|
|
2026-03-05 17:46:05 +08:00
|
|
|
echo ""
|
|
|
|
|
|
2025-12-25 11:24:12 +08:00
|
|
|
for detail in "${app_details[@]}"; do
|
2026-02-11 15:35:45 +09:00
|
|
|
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"
|
2025-10-11 11:40:01 +08:00
|
|
|
local app_size_display=$(bytes_to_human "$((total_kb * 1024))")
|
|
|
|
|
|
2026-01-13 10:44:48 +08:00
|
|
|
local brew_tag=""
|
|
|
|
|
[[ "$is_brew_cask" == "true" ]] && brew_tag=" ${CYAN}[Brew]${NC}"
|
2026-01-26 14:36:06 +08:00
|
|
|
echo -e "${BLUE}${ICON_CONFIRM}${NC} ${app_name}${brew_tag} ${GRAY}, ${app_size_display}${NC}"
|
2026-01-13 10:44:48 +08:00
|
|
|
|
2026-01-14 08:55:41 -05:00
|
|
|
# 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")
|
2026-02-11 15:35:45 +09:00
|
|
|
local diag_system_display
|
|
|
|
|
diag_system_display=$(decode_file_list "$encoded_diag_system" "$app_name")
|
|
|
|
|
[[ -n "$diag_system_display" ]] && system_files=$(
|
|
|
|
|
[[ -n "$system_files" ]] && echo "$system_files"
|
|
|
|
|
echo "$diag_system_display"
|
|
|
|
|
)
|
2026-01-14 08:55:41 -05:00
|
|
|
|
|
|
|
|
echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${app_path/$HOME/~}"
|
|
|
|
|
|
2026-02-11 14:22:22 +08:00
|
|
|
# Show all related files so users can fully review before deletion.
|
2026-01-14 08:55:41 -05:00
|
|
|
while IFS= read -r file; do
|
|
|
|
|
if [[ -n "$file" && -e "$file" ]]; then
|
2026-02-11 14:22:22 +08:00
|
|
|
echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${file/$HOME/~}"
|
2026-01-14 08:55:41 -05:00
|
|
|
fi
|
2026-01-17 01:53:40 +00:00
|
|
|
done <<< "$related_files"
|
2026-01-14 08:55:41 -05:00
|
|
|
|
2026-02-11 14:22:22 +08:00
|
|
|
# Show all system files so users can fully review before deletion.
|
2026-01-14 08:55:41 -05:00
|
|
|
while IFS= read -r file; do
|
|
|
|
|
if [[ -n "$file" && -e "$file" ]]; then
|
2026-02-11 14:22:22 +08:00
|
|
|
echo -e " ${BLUE}${ICON_WARNING}${NC} System: $file"
|
2026-01-13 10:44:48 +08:00
|
|
|
fi
|
2026-01-17 01:53:40 +00:00
|
|
|
done <<< "$system_files"
|
2025-10-11 11:40:01 +08:00
|
|
|
done
|
|
|
|
|
|
2025-12-31 16:23:31 +08:00
|
|
|
# Confirmation before requesting sudo.
|
2025-10-09 14:24:00 +08:00
|
|
|
local app_total=${#selected_apps[@]}
|
|
|
|
|
local app_text="app"
|
|
|
|
|
[[ $app_total -gt 1 ]] && app_text="apps"
|
2025-10-11 22:43:18 +08:00
|
|
|
|
|
|
|
|
echo ""
|
|
|
|
|
local removal_note="Remove ${app_total} ${app_text}"
|
2026-01-26 14:36:06 +08:00
|
|
|
[[ -n "$size_display" ]] && removal_note+=", ${size_display}"
|
2025-10-09 14:24:00 +08:00
|
|
|
if [[ ${#running_apps[@]} -gt 0 ]]; then
|
2025-11-19 11:33:15 +08:00
|
|
|
removal_note+=" ${YELLOW}[Running]${NC}"
|
2025-10-09 14:24:00 +08:00
|
|
|
fi
|
2025-11-19 11:33:15 +08:00
|
|
|
echo -ne "${PURPLE}${ICON_ARROW}${NC} ${removal_note} ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: "
|
2025-10-11 22:43:18 +08:00
|
|
|
|
2025-11-20 15:15:33 +08:00
|
|
|
drain_pending_input # Clean up any pending input before confirmation
|
2025-10-09 14:24:00 +08:00
|
|
|
IFS= read -r -s -n1 key || key=""
|
2025-11-15 13:18:57 +08:00
|
|
|
drain_pending_input # Clean up any escape sequence remnants
|
2025-10-09 14:24:00 +08:00
|
|
|
case "$key" in
|
2026-01-17 01:53:40 +00:00
|
|
|
$'\e' | q | Q)
|
|
|
|
|
echo ""
|
|
|
|
|
echo ""
|
2026-01-22 17:45:53 +08:00
|
|
|
_restore_uninstall_traps
|
2026-01-17 01:53:40 +00:00
|
|
|
return 0
|
|
|
|
|
;;
|
|
|
|
|
"" | $'\n' | $'\r' | y | Y)
|
|
|
|
|
echo "" # Move to next line
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
echo ""
|
|
|
|
|
echo ""
|
2026-01-22 17:45:53 +08:00
|
|
|
_restore_uninstall_traps
|
2026-01-17 01:53:40 +00:00
|
|
|
return 0
|
|
|
|
|
;;
|
2025-10-09 14:24:00 +08:00
|
|
|
esac
|
|
|
|
|
|
2026-01-20 11:53:45 +08:00
|
|
|
# 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
|
|
|
|
|
|
2026-03-05 17:46:05 +08:00
|
|
|
# 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.
|
Add dry-run support across destructive commands (#516)
* chore: update contributors [skip ci]
* Add dry-run support across destructive commands
Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped).
* test(purge): keep dev-compatible purge coverage
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tw93 <hitw93@gmail.com>
2026-03-01 20:03:22 +08:00
|
|
|
if [[ ${#sudo_apps[@]} -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then
|
2026-03-05 17:46:05 +08:00
|
|
|
if ! ensure_sudo_session "Admin required for system apps: ${sudo_apps[*]}"; then
|
|
|
|
|
echo ""
|
|
|
|
|
log_error "Admin access denied"
|
|
|
|
|
_restore_uninstall_traps
|
|
|
|
|
return 1
|
2025-10-03 11:38:54 +08:00
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
2026-01-13 10:44:48 +08:00
|
|
|
# Perform uninstallations with per-app progress feedback
|
2025-10-08 18:01:46 +08:00
|
|
|
local success_count=0 failed_count=0
|
2026-03-05 17:46:05 +08:00
|
|
|
local brew_apps_removed=0 # Track successful brew uninstalls for silent autoremove
|
2025-10-08 18:01:46 +08:00
|
|
|
local -a failed_items=()
|
2025-10-09 14:24:00 +08:00
|
|
|
local -a success_items=()
|
2026-01-13 10:44:48 +08:00
|
|
|
local current_index=0
|
2025-09-25 20:22:51 +08:00
|
|
|
for detail in "${app_details[@]}"; do
|
2026-02-28 11:10:18 +08:00
|
|
|
current_index=$((current_index + 1))
|
2026-02-11 15:35:45 +09:00
|
|
|
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 encoded_diag_system <<< "$detail"
|
2025-11-14 11:38:25 +08:00
|
|
|
local related_files=$(decode_file_list "$encoded_files" "$app_name")
|
2025-12-02 10:58:40 +08:00
|
|
|
local system_files=$(decode_file_list "$encoded_system_files" "$app_name")
|
2026-02-11 15:35:45 +09:00
|
|
|
local diag_system=$(decode_file_list "$encoded_diag_system" "$app_name")
|
2025-10-08 18:01:46 +08:00
|
|
|
local reason=""
|
2026-02-03 17:36:15 +08:00
|
|
|
local suggestion=""
|
2025-12-25 11:24:12 +08:00
|
|
|
|
2026-01-13 10:44:48 +08:00
|
|
|
# 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
|
|
|
|
|
|
2025-12-31 16:23:31 +08:00
|
|
|
# Stop Launch Agents/Daemons before removal.
|
2025-12-02 10:58:40 +08:00
|
|
|
local has_system_files="false"
|
|
|
|
|
[[ -n "$system_files" ]] && has_system_files="true"
|
2026-01-16 12:54:21 +08:00
|
|
|
|
2025-12-02 10:58:40 +08:00
|
|
|
stop_launch_services "$bundle_id" "$has_system_files"
|
2026-02-24 14:26:33 +08:00
|
|
|
unregister_app_bundle "$app_path"
|
2025-12-02 10:58:40 +08:00
|
|
|
|
2026-01-15 11:39:33 +08:00
|
|
|
# Remove from Login Items
|
|
|
|
|
remove_login_item "$app_name" "$bundle_id"
|
|
|
|
|
|
2025-10-11 11:40:01 +08:00
|
|
|
if ! force_kill_app "$app_name" "$app_path"; then
|
2025-10-08 18:01:46 +08:00
|
|
|
reason="still running"
|
2025-10-03 11:38:54 +08:00
|
|
|
fi
|
2025-12-02 10:58:40 +08:00
|
|
|
|
2025-12-31 16:23:31 +08:00
|
|
|
# Remove the application only if not running.
|
2026-01-19 16:53:51 +08:00
|
|
|
# Stop spinner before any removal attempt (avoids mixed output on errors)
|
|
|
|
|
[[ -t 1 ]] && stop_inline_spinner
|
|
|
|
|
|
2026-01-14 08:55:41 -05:00
|
|
|
local used_brew_successfully=false
|
2025-10-08 18:01:46 +08:00
|
|
|
if [[ -z "$reason" ]]; then
|
2026-01-13 10:44:48 +08:00
|
|
|
if [[ "$is_brew_cask" == "true" && -n "$cask_name" ]]; then
|
2026-01-14 08:55:41 -05:00
|
|
|
# Use brew_uninstall_cask helper (handles env vars, timeout, verification)
|
|
|
|
|
if brew_uninstall_cask "$cask_name" "$app_path"; then
|
|
|
|
|
used_brew_successfully=true
|
|
|
|
|
else
|
2026-01-13 10:44:48 +08:00
|
|
|
# Fallback to manual removal if brew fails
|
|
|
|
|
if [[ "$needs_sudo" == true ]]; then
|
2026-01-26 15:22:16 +08:00
|
|
|
if ! safe_sudo_remove "$app_path"; then
|
|
|
|
|
reason="brew failed, manual removal failed"
|
|
|
|
|
fi
|
2026-01-13 10:44:48 +08:00
|
|
|
else
|
2026-01-26 15:22:16 +08:00
|
|
|
if ! safe_remove "$app_path" true; then
|
|
|
|
|
reason="brew failed, manual removal failed"
|
|
|
|
|
fi
|
2026-01-13 10:44:48 +08:00
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
elif [[ "$needs_sudo" == true ]]; then
|
2026-02-02 17:05:42 +08:00
|
|
|
if [[ -L "$app_path" ]]; then
|
|
|
|
|
local link_target
|
|
|
|
|
link_target=$(readlink "$app_path" 2> /dev/null)
|
|
|
|
|
if [[ -n "$link_target" ]]; then
|
|
|
|
|
local resolved_target="$link_target"
|
|
|
|
|
if [[ "$link_target" != /* ]]; then
|
|
|
|
|
local link_dir
|
|
|
|
|
link_dir=$(dirname "$app_path")
|
|
|
|
|
resolved_target=$(cd "$link_dir" 2> /dev/null && cd "$(dirname "$link_target")" 2> /dev/null && pwd)/$(basename "$link_target") 2> /dev/null || echo ""
|
|
|
|
|
fi
|
|
|
|
|
case "$resolved_target" in
|
|
|
|
|
/System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*)
|
|
|
|
|
reason="protected system symlink, cannot remove"
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
if ! safe_remove_symlink "$app_path" "true"; then
|
|
|
|
|
reason="failed to remove symlink"
|
|
|
|
|
fi
|
|
|
|
|
;;
|
|
|
|
|
esac
|
2025-12-25 11:24:12 +08:00
|
|
|
else
|
2026-02-02 17:05:42 +08:00
|
|
|
if ! safe_remove_symlink "$app_path" "true"; then
|
|
|
|
|
reason="failed to remove symlink"
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
else
|
Add dry-run support across destructive commands (#516)
* chore: update contributors [skip ci]
* Add dry-run support across destructive commands
Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped).
* test(purge): keep dev-compatible purge coverage
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tw93 <hitw93@gmail.com>
2026-03-01 20:03:22 +08:00
|
|
|
if is_uninstall_dry_run; then
|
|
|
|
|
if ! safe_remove "$app_path" true; then
|
|
|
|
|
reason="dry-run path validation failed"
|
|
|
|
|
fi
|
|
|
|
|
else
|
|
|
|
|
local ret=0
|
|
|
|
|
safe_sudo_remove "$app_path" || ret=$?
|
|
|
|
|
if [[ $ret -ne 0 ]]; then
|
|
|
|
|
local diagnosis
|
|
|
|
|
diagnosis=$(diagnose_removal_failure "$ret" "$app_name")
|
|
|
|
|
IFS='|' read -r reason suggestion <<< "$diagnosis"
|
|
|
|
|
fi
|
2025-12-25 11:24:12 +08:00
|
|
|
fi
|
|
|
|
|
fi
|
2025-10-03 11:38:54 +08:00
|
|
|
else
|
2026-01-26 15:22:16 +08:00
|
|
|
if ! safe_remove "$app_path" true; then
|
|
|
|
|
if [[ ! -w "$(dirname "$app_path")" ]]; then
|
|
|
|
|
reason="parent directory not writable"
|
|
|
|
|
else
|
|
|
|
|
reason="remove failed, check permissions"
|
|
|
|
|
fi
|
|
|
|
|
fi
|
2025-10-03 11:38:54 +08:00
|
|
|
fi
|
|
|
|
|
fi
|
2025-12-02 10:58:40 +08:00
|
|
|
|
2025-12-31 16:23:31 +08:00
|
|
|
# Remove related files if app removal succeeded.
|
2025-10-08 18:01:46 +08:00
|
|
|
if [[ -z "$reason" ]]; then
|
2026-01-17 01:53:40 +00:00
|
|
|
remove_file_list "$related_files" "false" > /dev/null
|
2026-01-14 08:55:41 -05:00
|
|
|
|
2026-02-11 15:35:45 +09:00
|
|
|
if [[ "$used_brew_successfully" == "true" ]]; then
|
|
|
|
|
remove_file_list "$diag_system" "true" > /dev/null
|
|
|
|
|
else
|
|
|
|
|
local system_all="$system_files"
|
|
|
|
|
if [[ -n "$diag_system" ]]; then
|
|
|
|
|
if [[ -n "$system_all" ]]; then
|
|
|
|
|
system_all+=$'\n'
|
|
|
|
|
fi
|
|
|
|
|
system_all+="$diag_system"
|
|
|
|
|
fi
|
|
|
|
|
remove_file_list "$system_all" "true" > /dev/null
|
2026-01-14 08:55:41 -05:00
|
|
|
fi
|
2025-12-02 10:58:40 +08:00
|
|
|
|
Add dry-run support across destructive commands (#516)
* chore: update contributors [skip ci]
* Add dry-run support across destructive commands
Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped).
* test(purge): keep dev-compatible purge coverage
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tw93 <hitw93@gmail.com>
2026-03-01 20:03:22 +08:00
|
|
|
# Defaults writes are side effects that should never run in dry-run mode.
|
2025-12-25 11:24:12 +08:00
|
|
|
if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then
|
Add dry-run support across destructive commands (#516)
* chore: update contributors [skip ci]
* Add dry-run support across destructive commands
Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped).
* test(purge): keep dev-compatible purge coverage
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tw93 <hitw93@gmail.com>
2026-03-01 20:03:22 +08:00
|
|
|
if is_uninstall_dry_run; then
|
|
|
|
|
debug_log "[DRY RUN] Would clear defaults domain: $bundle_id"
|
|
|
|
|
else
|
|
|
|
|
if defaults read "$bundle_id" &> /dev/null; then
|
|
|
|
|
defaults delete "$bundle_id" 2> /dev/null || true
|
|
|
|
|
fi
|
2025-12-25 11:24:12 +08:00
|
|
|
fi
|
|
|
|
|
|
2025-12-31 16:23:31 +08:00
|
|
|
# ByHost preferences (machine-specific).
|
2026-01-26 15:43:11 +08:00
|
|
|
if [[ -d "$HOME/Library/Preferences/ByHost" ]]; then
|
|
|
|
|
if [[ "$bundle_id" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
|
|
|
|
while IFS= read -r -d '' plist_file; do
|
|
|
|
|
safe_remove "$plist_file" true > /dev/null || true
|
|
|
|
|
done < <(command find "$HOME/Library/Preferences/ByHost" -maxdepth 1 -type f -name "${bundle_id}.*.plist" -print0 2> /dev/null || true)
|
|
|
|
|
else
|
|
|
|
|
debug_log "Skipping ByHost cleanup, invalid bundle id: $bundle_id"
|
|
|
|
|
fi
|
2025-12-25 11:24:12 +08:00
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
2026-01-19 16:53:51 +08:00
|
|
|
# Show success
|
2026-01-13 10:44:48 +08:00
|
|
|
if [[ -t 1 ]]; then
|
|
|
|
|
if [[ ${#app_details[@]} -gt 1 ]]; then
|
2026-02-23 11:34:22 +08:00
|
|
|
echo -e "${GREEN}${ICON_SUCCESS}${NC} [$current_index/${#app_details[@]}] ${app_name}"
|
2026-01-13 10:44:48 +08:00
|
|
|
else
|
2026-02-23 11:34:22 +08:00
|
|
|
echo -e "${GREEN}${ICON_SUCCESS}${NC} ${app_name}"
|
2026-01-13 10:44:48 +08:00
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
2026-02-28 11:10:18 +08:00
|
|
|
total_size_freed=$((total_size_freed + total_kb))
|
|
|
|
|
success_count=$((success_count + 1))
|
|
|
|
|
[[ "$used_brew_successfully" == "true" ]] && brew_apps_removed=$((brew_apps_removed + 1))
|
|
|
|
|
files_cleaned=$((files_cleaned + 1))
|
|
|
|
|
total_items=$((total_items + 1))
|
2026-02-03 17:36:15 +08:00
|
|
|
success_items+=("$app_path")
|
2025-09-25 20:22:51 +08:00
|
|
|
else
|
2026-01-13 10:44:48 +08:00
|
|
|
if [[ -t 1 ]]; then
|
|
|
|
|
if [[ ${#app_details[@]} -gt 1 ]]; then
|
2026-01-26 14:36:06 +08:00
|
|
|
echo -e "${ICON_ERROR} [$current_index/${#app_details[@]}] ${app_name} ${GRAY}, $reason${NC}"
|
2026-01-13 10:44:48 +08:00
|
|
|
else
|
2026-01-19 16:53:51 +08:00
|
|
|
echo -e "${ICON_ERROR} ${app_name} failed: $reason"
|
2026-01-13 10:44:48 +08:00
|
|
|
fi
|
2026-02-02 17:05:42 +08:00
|
|
|
if [[ -n "${suggestion:-}" ]]; then
|
2026-02-23 11:34:22 +08:00
|
|
|
echo -e "${GRAY} ${ICON_REVIEW} ${suggestion}${NC}"
|
2026-02-02 17:05:42 +08:00
|
|
|
fi
|
2026-01-13 10:44:48 +08:00
|
|
|
fi
|
|
|
|
|
|
2026-02-28 11:10:18 +08:00
|
|
|
failed_count=$((failed_count + 1))
|
2026-02-02 17:05:42 +08:00
|
|
|
failed_items+=("$app_name:$reason:${suggestion:-}")
|
2025-09-25 20:22:51 +08:00
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
|
2025-10-08 18:01:46 +08:00
|
|
|
# Summary
|
2025-10-11 15:02:15 +08:00
|
|
|
local freed_display
|
|
|
|
|
freed_display=$(bytes_to_human "$((total_size_freed * 1024))")
|
|
|
|
|
|
|
|
|
|
local summary_status="success"
|
|
|
|
|
local -a summary_details=()
|
|
|
|
|
|
2025-10-09 14:24:00 +08:00
|
|
|
if [[ $success_count -gt 0 ]]; then
|
2025-10-11 22:43:18 +08:00
|
|
|
local success_text="app"
|
|
|
|
|
[[ $success_count -gt 1 ]] && success_text="apps"
|
|
|
|
|
local success_line="Removed ${success_count} ${success_text}"
|
Add dry-run support across destructive commands (#516)
* chore: update contributors [skip ci]
* Add dry-run support across destructive commands
Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped).
* test(purge): keep dev-compatible purge coverage
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tw93 <hitw93@gmail.com>
2026-03-01 20:03:22 +08:00
|
|
|
if is_uninstall_dry_run; then
|
|
|
|
|
success_line="Would remove ${success_count} ${success_text}"
|
|
|
|
|
fi
|
2025-10-11 22:43:18 +08:00
|
|
|
if [[ -n "$freed_display" ]]; then
|
Add dry-run support across destructive commands (#516)
* chore: update contributors [skip ci]
* Add dry-run support across destructive commands
Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped).
* test(purge): keep dev-compatible purge coverage
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tw93 <hitw93@gmail.com>
2026-03-01 20:03:22 +08:00
|
|
|
if is_uninstall_dry_run; then
|
|
|
|
|
success_line+=", would free ${GREEN}${freed_display}${NC}"
|
|
|
|
|
else
|
|
|
|
|
success_line+=", freed ${GREEN}${freed_display}${NC}"
|
|
|
|
|
fi
|
2025-10-11 22:43:18 +08:00
|
|
|
fi
|
2025-10-12 15:43:45 +08:00
|
|
|
|
2025-12-31 16:23:31 +08:00
|
|
|
# Format app list with max 3 per line.
|
2026-02-03 17:36:15 +08:00
|
|
|
if [[ ${#success_items[@]} -gt 0 ]]; then
|
2025-10-12 15:43:45 +08:00
|
|
|
local idx=0
|
|
|
|
|
local is_first_line=true
|
|
|
|
|
local current_line=""
|
|
|
|
|
|
2026-02-03 17:36:15 +08:00
|
|
|
for success_path in "${success_items[@]}"; do
|
|
|
|
|
local display_name
|
|
|
|
|
display_name=$(basename "$success_path" .app)
|
|
|
|
|
local display_item="${GREEN}${display_name}${NC}"
|
2025-10-12 15:43:45 +08:00
|
|
|
|
2025-10-12 20:49:10 +08:00
|
|
|
if ((idx % 3 == 0)); then
|
2025-10-12 15:43:45 +08:00
|
|
|
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
|
2026-02-28 11:10:18 +08:00
|
|
|
idx=$((idx + 1))
|
2025-10-11 22:43:18 +08:00
|
|
|
done
|
2025-10-12 15:43:45 +08:00
|
|
|
if [[ -n "$current_line" ]]; then
|
|
|
|
|
summary_details+=("$current_line")
|
2025-10-11 22:43:18 +08:00
|
|
|
fi
|
2025-10-12 15:43:45 +08:00
|
|
|
else
|
|
|
|
|
summary_details+=("$success_line")
|
2025-10-11 22:43:18 +08:00
|
|
|
fi
|
2025-10-09 14:24:00 +08:00
|
|
|
fi
|
2025-10-11 15:02:15 +08:00
|
|
|
|
2025-09-25 20:22:51 +08:00
|
|
|
if [[ $failed_count -gt 0 ]]; then
|
2025-10-11 15:02:15 +08:00
|
|
|
summary_status="warn"
|
|
|
|
|
|
2025-10-09 14:24:00 +08:00
|
|
|
local failed_names=()
|
|
|
|
|
for item in "${failed_items[@]}"; do
|
|
|
|
|
local name=${item%%:*}
|
|
|
|
|
failed_names+=("$name")
|
|
|
|
|
done
|
|
|
|
|
local failed_list="${failed_names[*]}"
|
|
|
|
|
|
2025-10-11 15:02:15 +08:00
|
|
|
local reason_summary="could not be removed"
|
2026-02-02 17:05:42 +08:00
|
|
|
local suggestion_text=""
|
2025-10-08 18:01:46 +08:00
|
|
|
if [[ $failed_count -eq 1 ]]; then
|
2026-02-02 17:05:42 +08:00
|
|
|
# Extract reason and suggestion from format: app:reason:suggestion
|
|
|
|
|
local item="${failed_items[0]}"
|
|
|
|
|
local without_app="${item#*:}"
|
|
|
|
|
local first_reason="${without_app%%:*}"
|
|
|
|
|
local first_suggestion="${without_app#*:}"
|
|
|
|
|
|
|
|
|
|
# If suggestion is same as reason, there was no suggestion part
|
|
|
|
|
# Also check if suggestion is empty
|
|
|
|
|
if [[ "$first_suggestion" != "$first_reason" && -n "$first_suggestion" ]]; then
|
2026-02-23 11:34:22 +08:00
|
|
|
suggestion_text="${GRAY}${ICON_REVIEW} ${first_suggestion}${NC}"
|
2026-02-02 17:05:42 +08:00
|
|
|
fi
|
|
|
|
|
|
2025-10-09 14:24:00 +08:00
|
|
|
case "$first_reason" in
|
2026-01-17 01:53:40 +00:00
|
|
|
still*running*) reason_summary="is still running" ;;
|
|
|
|
|
remove*failed*) reason_summary="could not be removed" ;;
|
|
|
|
|
permission*denied*) reason_summary="permission denied" ;;
|
2026-01-26 14:36:06 +08:00
|
|
|
owned*by*) reason_summary="$first_reason, try with sudo" ;;
|
2026-01-17 01:53:40 +00:00
|
|
|
*) reason_summary="$first_reason" ;;
|
2025-10-09 14:24:00 +08:00
|
|
|
esac
|
2025-10-08 18:01:46 +08:00
|
|
|
fi
|
2026-02-23 11:34:22 +08:00
|
|
|
summary_details+=("${ICON_LIST} Failed: ${RED}${failed_list}${NC} ${reason_summary}")
|
2026-02-02 17:05:42 +08:00
|
|
|
if [[ -n "$suggestion_text" ]]; then
|
|
|
|
|
summary_details+=("$suggestion_text")
|
|
|
|
|
fi
|
2025-10-11 15:02:15 +08:00
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [[ $success_count -eq 0 && $failed_count -eq 0 ]]; then
|
|
|
|
|
summary_status="info"
|
|
|
|
|
summary_details+=("No applications were uninstalled.")
|
2025-09-25 20:22:51 +08:00
|
|
|
fi
|
2025-10-11 15:02:15 +08:00
|
|
|
|
2025-12-17 11:56:39 +08:00
|
|
|
local title="Uninstall complete"
|
|
|
|
|
if [[ "$summary_status" == "warn" ]]; then
|
|
|
|
|
title="Uninstall incomplete"
|
|
|
|
|
fi
|
Add dry-run support across destructive commands (#516)
* chore: update contributors [skip ci]
* Add dry-run support across destructive commands
Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped).
* test(purge): keep dev-compatible purge coverage
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tw93 <hitw93@gmail.com>
2026-03-01 20:03:22 +08:00
|
|
|
if is_uninstall_dry_run; then
|
|
|
|
|
title="Uninstall dry run complete"
|
|
|
|
|
fi
|
2025-12-17 11:56:39 +08:00
|
|
|
|
2026-01-13 10:44:48 +08:00
|
|
|
echo ""
|
2025-12-17 11:56:39 +08:00
|
|
|
print_summary_block "$title" "${summary_details[@]}"
|
2025-10-11 22:43:18 +08:00
|
|
|
printf '\n'
|
2025-09-25 20:22:51 +08:00
|
|
|
|
2026-03-05 17:46:05 +08:00
|
|
|
# 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
|
2026-02-28 11:22:41 +08:00
|
|
|
fi
|
2026-01-14 08:55:41 -05:00
|
|
|
|
Add dry-run support across destructive commands (#516)
* chore: update contributors [skip ci]
* Add dry-run support across destructive commands
Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped).
* test(purge): keep dev-compatible purge coverage
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tw93 <hitw93@gmail.com>
2026-03-01 20:03:22 +08:00
|
|
|
# Clean up Dock entries for uninstalled apps.
|
|
|
|
|
if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then
|
|
|
|
|
if is_uninstall_dry_run; then
|
|
|
|
|
log_info "[DRY RUN] Would refresh LaunchServices and update Dock entries"
|
|
|
|
|
else
|
2026-03-05 17:46:05 +08:00
|
|
|
(
|
|
|
|
|
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
|
Add dry-run support across destructive commands (#516)
* chore: update contributors [skip ci]
* Add dry-run support across destructive commands
Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped).
* test(purge): keep dev-compatible purge coverage
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tw93 <hitw93@gmail.com>
2026-03-01 20:03:22 +08:00
|
|
|
fi
|
2025-10-11 11:40:01 +08:00
|
|
|
fi
|
|
|
|
|
|
2026-02-02 17:05:42 +08:00
|
|
|
_cleanup_sudo_keepalive
|
2025-10-03 11:38:54 +08:00
|
|
|
|
2026-01-20 11:53:45 +08:00
|
|
|
# Disable uninstall mode
|
|
|
|
|
unset MOLE_UNINSTALL_MODE
|
|
|
|
|
|
2026-01-22 17:45:53 +08:00
|
|
|
_restore_uninstall_traps
|
|
|
|
|
unset -f _restore_uninstall_traps
|
|
|
|
|
|
2026-02-28 11:10:18 +08:00
|
|
|
total_size_cleaned=$((total_size_cleaned + total_size_freed))
|
2025-10-08 18:01:46 +08:00
|
|
|
unset failed_items
|
2025-10-03 13:56:53 +08:00
|
|
|
}
|