1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-04 15:39:42 +00:00

Merge branch 'dev' into fix/harden-brew-uninstall

This commit is contained in:
Tw93
2026-01-15 14:00:20 +08:00
committed by GitHub
12 changed files with 132 additions and 70 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 320 KiB

View File

@@ -2,7 +2,7 @@
<div align="center"> <div align="center">
**Status:** PASSED | **Risk Level:** LOW | **Version:** 1.19.0 (2026-01-09) **Status:** PASSED | **Risk Level:** LOW | **Version:** 1.21.0 (2026-01-15)
</div> </div>
@@ -12,9 +12,9 @@
| Attribute | Details | | Attribute | Details |
|-----------|---------| |-----------|---------|
| Audit Date | January 9, 2026 | | Audit Date | January 15, 2026 |
| Audit Conclusion | **PASSED** | | Audit Conclusion | **PASSED** |
| Mole Version | V1.19.0 | | Mole Version | V1.21.0 |
| Audited Branch | `main` (HEAD) | | Audited Branch | `main` (HEAD) |
| Scope | Shell scripts, Go binaries, Configuration | | Scope | Shell scripts, Go binaries, Configuration |
| Methodology | Static analysis, Threat modeling, Code review | | Methodology | Static analysis, Threat modeling, Code review |
@@ -176,18 +176,18 @@ For user-selected app removal:
| AI & LLM Tools | Cursor, Claude, ChatGPT, Ollama, LM Studio | Protects models, tokens, and sessions | | AI & LLM Tools | Cursor, Claude, ChatGPT, Ollama, LM Studio | Protects models, tokens, and sessions |
| Startup Items | `com.apple.*` LaunchAgents/Daemons | System items unconditionally skipped | | Startup Items | `com.apple.*` LaunchAgents/Daemons | System items unconditionally skipped |
**Orphaned Helper Cleanup (`opt_startup_items_cleanup`):** **LaunchAgent/LaunchDaemon Cleanup During Uninstallation:**
Removes LaunchAgents/Daemons whose associated app has been uninstalled: When users uninstall applications via `mo uninstall`, Mole automatically removes associated LaunchAgent and LaunchDaemon plists:
- Checks `AssociatedBundleIdentifiers` to detect orphans. - Scans `~/Library/LaunchAgents`, `~/Library/LaunchDaemons`, `/Library/LaunchAgents`, `/Library/LaunchDaemons`
- Skips all `com.apple.*` system items. - Matches both exact bundle ID (`com.example.app.plist`) and app name patterns (`*AppName*.plist`)
- Skips paths under `/System/*`, `/usr/bin/*`, `/usr/lib/*`, `/usr/sbin/*`, `/Library/Apple/*`. - Skips all `com.apple.*` system items via `should_protect_path()` validation
- Uses `safe_remove` / `safe_sudo_remove` with path validation. - Unloads services via `launchctl` before deletion (via `stop_launch_services()`)
- Unloads service via `launchctl` before deletion. - **Safer than orphan detection:** Only removes plists when the associated app is explicitly being uninstalled
- **Timeout Protection:** 10-second limit on `mdfind` operations. - Prevents accumulation of orphaned startup items that persist after app removal
**Code:** `lib/optimize/tasks.sh:opt_startup_items_cleanup()` **Code:** `lib/core/app_protection.sh:find_app_files()`, `lib/uninstall/batch.sh:stop_launch_services()`
### Crash Safety & Atomic Operations ### Crash Safety & Atomic Operations

View File

@@ -99,11 +99,11 @@ perform_purge() {
truncate_path() { truncate_path() {
local path="$1" local path="$1"
local term_cols local term_cols
term_cols=$(tput cols 2>/dev/null || echo 80) term_cols=$(tput cols 2> /dev/null || echo 80)
# Reserve some space for the spinner and text (approx 20 chars) # Reserve some space for the spinner and text (approx 20 chars)
local max_len=$((term_cols - 20)) local max_len=$((term_cols - 20))
# Ensure a reasonable minimum width # Ensure a reasonable minimum width
if (( max_len < 40 )); then if ((max_len < 40)); then
max_len=40 max_len=40
fi fi

View File

@@ -32,24 +32,6 @@ clean_empty_library_items() {
safe_clean "${empty_dirs[@]}" "Empty Library folders" safe_clean "${empty_dirs[@]}" "Empty Library folders"
fi fi
# Clean empty files in Library root (skipping .localized and other sentinels)
local -a empty_files=()
while IFS= read -r -d '' file; do
[[ -f "$file" ]] || continue
# Protect .localized and potential system sentinels
if [[ "$(basename "$file")" == ".localized" ]]; then
continue
fi
if is_path_whitelisted "$file"; then
continue
fi
empty_files+=("$file")
done < <(find "$HOME/Library" -mindepth 1 -maxdepth 1 -type f -empty -print0 2> /dev/null)
if [[ ${#empty_files[@]} -gt 0 ]]; then
safe_clean "${empty_files[@]}" "Empty Library files"
fi
# 2. Clean empty subdirectories in Application Support and other key locations # 2. Clean empty subdirectories in Application Support and other key locations
# Iteratively remove empty directories until no more are found # Iteratively remove empty directories until no more are found
local -a key_locations=( local -a key_locations=(
@@ -102,8 +84,8 @@ clean_chrome_old_versions() {
"$HOME/Applications/Google Chrome.app" "$HOME/Applications/Google Chrome.app"
) )
# Use -f to match Chrome Helper processes as well # Match the exact Chrome process name to avoid false positives
if pgrep -f "Google Chrome" > /dev/null 2>&1; then if pgrep -x "Google Chrome" > /dev/null 2>&1; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Google Chrome running · old versions cleanup skipped" echo -e " ${YELLOW}${ICON_WARNING}${NC} Google Chrome running · old versions cleanup skipped"
return 0 return 0
fi fi
@@ -182,8 +164,8 @@ clean_edge_old_versions() {
"$HOME/Applications/Microsoft Edge.app" "$HOME/Applications/Microsoft Edge.app"
) )
# Use -f to match Edge Helper processes as well # Match the exact Edge process name to avoid false positives (e.g., Microsoft Teams)
if pgrep -f "Microsoft Edge" > /dev/null 2>&1; then if pgrep -x "Microsoft Edge" > /dev/null 2>&1; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · old versions cleanup skipped" echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · old versions cleanup skipped"
return 0 return 0
fi fi
@@ -260,7 +242,7 @@ clean_edge_updater_old_versions() {
local updater_dir="$HOME/Library/Application Support/Microsoft/EdgeUpdater/apps/msedge-stable" local updater_dir="$HOME/Library/Application Support/Microsoft/EdgeUpdater/apps/msedge-stable"
[[ -d "$updater_dir" ]] || return 0 [[ -d "$updater_dir" ]] || return 0
if pgrep -f "Microsoft Edge" > /dev/null 2>&1; then if pgrep -x "Microsoft Edge" > /dev/null 2>&1; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · updater cleanup skipped" echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · updater cleanup skipped"
return 0 return 0
fi fi

View File

@@ -665,6 +665,7 @@ find_app_files() {
"$HOME/Library/HTTPStorages/$bundle_id" "$HOME/Library/HTTPStorages/$bundle_id"
"$HOME/Library/Cookies/$bundle_id.binarycookies" "$HOME/Library/Cookies/$bundle_id.binarycookies"
"$HOME/Library/LaunchAgents/$bundle_id.plist" "$HOME/Library/LaunchAgents/$bundle_id.plist"
"$HOME/Library/LaunchDaemons/$bundle_id.plist"
"$HOME/Library/Application Scripts/$bundle_id" "$HOME/Library/Application Scripts/$bundle_id"
"$HOME/Library/Services/$app_name.workflow" "$HOME/Library/Services/$app_name.workflow"
"$HOME/Library/QuickLook/$app_name.qlgenerator" "$HOME/Library/QuickLook/$app_name.qlgenerator"
@@ -739,12 +740,19 @@ find_app_files() {
fi fi
fi fi
# Launch Agents by name (special handling) # Launch Agents and Daemons by name (special handling)
if [[ ${#app_name} -gt 3 ]] && [[ -d ~/Library/LaunchAgents ]]; then if [[ ${#app_name} -gt 3 ]]; then
if [[ -d ~/Library/LaunchAgents ]]; then
while IFS= read -r -d '' plist; do while IFS= read -r -d '' plist; do
files_to_clean+=("$plist") files_to_clean+=("$plist")
done < <(command find ~/Library/LaunchAgents -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null) done < <(command find ~/Library/LaunchAgents -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null)
fi fi
if [[ -d ~/Library/LaunchDaemons ]]; then
while IFS= read -r -d '' plist; do
files_to_clean+=("$plist")
done < <(command find ~/Library/LaunchDaemons -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null)
fi
fi
# Handle specialized toolchains and development environments # Handle specialized toolchains and development environments
# 1. DevEco-Studio (Huawei) # 1. DevEco-Studio (Huawei)

View File

@@ -124,14 +124,14 @@ remove_apps_from_dock() {
local changed=false local changed=false
for target in "${targets[@]}"; do for target in "${targets[@]}"; do
local app_path="$target" local app_path="$target"
local app_name
app_name=$(basename "$app_path" .app)
# Normalize path for comparison - realpath might fail if app is already deleted # Normalize path for comparison - realpath might fail if app is already deleted
local full_path local full_path
full_path=$(cd "$(dirname "$app_path")" 2> /dev/null && pwd || echo "") full_path=$(cd "$(dirname "$app_path")" 2> /dev/null && pwd || echo "")
[[ -n "$full_path" ]] && full_path="$full_path/$(basename "$app_path")" [[ -n "$full_path" ]] && full_path="$full_path/$(basename "$app_path")"
# URL-encode the path for matching against Dock URLs (spaces -> %20)
local encoded_path="${full_path// /%20}"
# Find the index of the app in persistent-apps # Find the index of the app in persistent-apps
local i=0 local i=0
while true; do while true; do
@@ -141,18 +141,19 @@ remove_apps_from_dock() {
local url local url
url=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-data:_CFURLString" "$plist" 2> /dev/null || echo "") url=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-data:_CFURLString" "$plist" 2> /dev/null || echo "")
[[ -z "$url" ]] && {
((i++))
continue
}
# Match by label or by path (parsing the CFURLString which is usually a file:// URL) # Match by URL-encoded path to handle spaces in app names
if [[ "$label" == "$app_name" ]] || [[ "$url" == *"$app_name.app"* ]]; then if [[ -n "$encoded_path" && "$url" == *"$encoded_path"* ]]; 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 if /usr/libexec/PlistBuddy -c "Delete :persistent-apps:$i" "$plist" 2> /dev/null; then
changed=true changed=true
# After deletion, current index i now points to the next item # After deletion, current index i now points to the next item
continue continue
fi fi
fi fi
fi
((i++)) ((i++))
done done
done done

View File

@@ -864,7 +864,14 @@ 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%?}"
need_full_redraw=true # Fast footer-only update in filter mode (avoid full redraw)
local filter_status="${filter_query:-_}"
local footer_row=$((items_per_page + 4))
printf "\033[%d;1H\033[2K" "$footer_row" >&2
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
fi fi
;; ;;
CHAR:*) CHAR:*)
@@ -873,7 +880,14 @@ 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"
need_full_redraw=true # Fast footer-only update in filter mode (avoid full redraw)
local filter_status="${filter_query:-_}"
local footer_row=$((items_per_page + 4))
printf "\033[%d;1H\033[2K" "$footer_row" >&2
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
fi fi
fi fi
;; ;;

View File

@@ -91,6 +91,40 @@ stop_launch_services() {
fi fi
} }
# Remove macOS Login Items for an app
remove_login_item() {
local app_name="$1"
local bundle_id="$2"
# Skip if no identifiers provided
[[ -z "$app_name" && -z "$bundle_id" ]] && return 0
# Strip .app suffix if present (login items don't include it)
local clean_name="${app_name%.app}"
# Remove from Login Items using index-based deletion (handles broken items)
if [[ -n "$clean_name" ]]; then
osascript <<-EOF 2>/dev/null || true
tell application "System Events"
try
set itemCount to count of login items
-- Delete in reverse order to avoid index shifting
repeat with i from itemCount to 1 by -1
try
set itemName to name of login item i
if itemName is "$clean_name" then
delete login item i
end if
end try
end repeat
end try
end tell
EOF
fi
}
# Remove files (handles symlinks, optional sudo). # Remove files (handles symlinks, optional sudo).
remove_file_list() { remove_file_list() {
local file_list="$1" local file_list="$1"
@@ -350,6 +384,9 @@ batch_uninstall_applications() {
[[ -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_login_item "$app_name" "$bundle_id"
if ! force_kill_app "$app_name" "$app_path"; then if ! force_kill_app "$app_name" "$app_path"; then
reason="still running" reason="still running"
fi fi

2
mole
View File

@@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh"
trap cleanup_temp_files EXIT INT TERM trap cleanup_temp_files EXIT INT TERM
# Version and update helpers # Version and update helpers
VERSION="1.20.0" VERSION="1.21.0"
MOLE_TAGLINE="Deep clean and optimize your Mac." MOLE_TAGLINE="Deep clean and optimize your Mac."
is_touchid_configured() { is_touchid_configured() {

View File

@@ -109,6 +109,7 @@ set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh" source "$PROJECT_ROOT/lib/clean/user.sh"
safe_clean() { echo "$2"; } safe_clean() { echo "$2"; }
WHITELIST_PATTERNS=()
mkdir -p "$HOME/Library/EmptyDir" mkdir -p "$HOME/Library/EmptyDir"
touch "$HOME/Library/empty.txt" touch "$HOME/Library/empty.txt"
clean_empty_library_items clean_empty_library_items

View File

@@ -37,6 +37,10 @@ create_app_artifacts() {
mkdir -p "$HOME/Library/Preferences/ByHost" mkdir -p "$HOME/Library/Preferences/ByHost"
touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist"
mkdir -p "$HOME/Library/Saved Application State/com.example.TestApp.savedState" mkdir -p "$HOME/Library/Saved Application State/com.example.TestApp.savedState"
mkdir -p "$HOME/Library/LaunchAgents"
touch "$HOME/Library/LaunchAgents/com.example.TestApp.plist"
mkdir -p "$HOME/Library/LaunchDaemons"
touch "$HOME/Library/LaunchDaemons/com.example.TestApp.plist"
} }
@test "find_app_files discovers user-level leftovers" { @test "find_app_files discovers user-level leftovers" {
@@ -55,6 +59,8 @@ EOF
[[ "$result" == *"Preferences/com.example.TestApp.plist"* ]] [[ "$result" == *"Preferences/com.example.TestApp.plist"* ]]
[[ "$result" == *"Saved Application State/com.example.TestApp.savedState"* ]] [[ "$result" == *"Saved Application State/com.example.TestApp.savedState"* ]]
[[ "$result" == *"Containers/com.example.TestApp"* ]] [[ "$result" == *"Containers/com.example.TestApp"* ]]
[[ "$result" == *"LaunchAgents/com.example.TestApp.plist"* ]]
[[ "$result" == *"LaunchDaemons/com.example.TestApp.plist"* ]]
} }
@test "calculate_total_size returns aggregate kilobytes" { @test "calculate_total_size returns aggregate kilobytes" {
@@ -114,6 +120,8 @@ batch_uninstall_applications
[[ ! -d "$HOME/Library/Application Support/TestApp" ]] || exit 1 [[ ! -d "$HOME/Library/Application Support/TestApp" ]] || exit 1
[[ ! -d "$HOME/Library/Caches/TestApp" ]] || exit 1 [[ ! -d "$HOME/Library/Caches/TestApp" ]] || exit 1
[[ ! -f "$HOME/Library/Preferences/com.example.TestApp.plist" ]] || exit 1 [[ ! -f "$HOME/Library/Preferences/com.example.TestApp.plist" ]] || exit 1
[[ ! -f "$HOME/Library/LaunchAgents/com.example.TestApp.plist" ]] || exit 1
[[ ! -f "$HOME/Library/LaunchDaemons/com.example.TestApp.plist" ]] || exit 1
EOF EOF
[ "$status" -eq 0 ] [ "$status" -eq 0 ]