1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-04 17:24:45 +00:00
Files
Mole/lib/core/app_protection.sh

1364 lines
45 KiB
Bash
Executable File

#!/bin/bash
# Mole - Application Protection
# System critical and data-protected application lists
set -euo pipefail
if [[ -n "${MOLE_APP_PROTECTION_LOADED:-}" ]]; then
return 0
fi
readonly MOLE_APP_PROTECTION_LOADED=1
_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[[ -z "${MOLE_BASE_LOADED:-}" ]] && source "$_MOLE_CORE_DIR/base.sh"
# Declare WHITELIST_PATTERNS if not already set (used by is_path_whitelisted)
if ! declare -p WHITELIST_PATTERNS &> /dev/null; then
declare -a WHITELIST_PATTERNS=()
fi
# Application Management
# ============================================================================
# Performance Note:
# - SYSTEM_CRITICAL_BUNDLES_FAST: Fast wildcard patterns for cleanup operations
# - SYSTEM_CRITICAL_BUNDLES: Detailed list for uninstall protection (lazy-loaded)
# ============================================================================
# Fast patterns for cleanup operations (used by should_protect_data)
# These wildcards provide adequate protection with minimal performance impact
readonly SYSTEM_CRITICAL_BUNDLES_FAST=(
"com.apple.*"
"loginwindow"
"dock"
"systempreferences"
"finder"
"safari"
"backgroundtaskmanagement*"
"keychain*"
"security*"
"bluetooth*"
"wifi*"
"network*"
"tcc"
"notification*"
"accessibility*"
"universalaccess*"
"HIToolbox*"
"textinput*"
"TextInput*"
"keyboard*"
"Keyboard*"
"inputsource*"
"InputSource*"
"keylayout*"
"KeyLayout*"
"GlobalPreferences"
".GlobalPreferences"
"org.pqrs.Karabiner*"
)
# Detailed list for uninstall protection
# Critical system components protected from uninstallation
# Note: We explicitly list system components instead of using "com.apple.*" wildcard
# to allow uninstallation of user-installed Apple apps (Xcode, Final Cut Pro, etc.)
readonly SYSTEM_CRITICAL_BUNDLES=(
# Core system applications (in /System/Applications/)
"com.apple.finder"
"com.apple.dock"
"com.apple.Safari"
"com.apple.mail"
"com.apple.systempreferences"
"com.apple.SystemSettings"
"com.apple.Settings*"
"com.apple.controlcenter*"
"com.apple.Spotlight"
"com.apple.notificationcenterui"
"com.apple.loginwindow"
"com.apple.Preview"
"com.apple.TextEdit"
"com.apple.Notes"
"com.apple.reminders"
"com.apple.iCal"
"com.apple.AddressBook"
"com.apple.Photos"
"com.apple.AppStore"
"com.apple.calculator"
"com.apple.Dictionary"
"com.apple.ScreenSharing"
"com.apple.ActivityMonitor"
"com.apple.Console"
"com.apple.DiskUtility"
"com.apple.KeychainAccess"
"com.apple.DigitalColorMeter"
"com.apple.grapher"
"com.apple.Terminal"
"com.apple.ScriptEditor2"
"com.apple.VoiceOverUtility"
"com.apple.BluetoothFileExchange"
"com.apple.print.PrinterProxy"
"com.apple.systempreferences*"
"com.apple.SystemProfiler"
"com.apple.FontBook"
"com.apple.ColorSyncUtility"
"com.apple.audio.AudioMIDISetup"
"com.apple.DirectoryUtility"
"com.apple.NetworkUtility"
"com.apple.exposelauncher"
"com.apple.MigrateAssistant"
"com.apple.RAIDUtility"
"com.apple.BootCampAssistant"
# System services and daemons
"com.apple.SecurityAgent"
"com.apple.CoreServices*"
"com.apple.SystemUIServer"
"com.apple.backgroundtaskmanagement*"
"com.apple.loginitems*"
"com.apple.sharedfilelist*"
"com.apple.sfl*"
"com.apple.coreservices*"
"com.apple.metadata*"
"com.apple.MobileSoftwareUpdate*"
"com.apple.SoftwareUpdate*"
"com.apple.installer*"
"com.apple.frameworks*"
"com.apple.security*"
"com.apple.keychain*"
"com.apple.trustd*"
"com.apple.securityd*"
"com.apple.cloudd*"
"com.apple.iCloud*"
"com.apple.WiFi*"
"com.apple.airport*"
"com.apple.Bluetooth*"
# Input methods (system built-in)
"com.apple.inputmethod.*"
"com.apple.inputsource*"
"com.apple.TextInput*"
"com.apple.CharacterPicker*"
"com.apple.PressAndHold*"
# Legacy pattern-based entries (non com.apple.*)
"loginwindow"
"dock"
"systempreferences"
"finder"
"safari"
"backgroundtaskmanagementagent"
"keychain*"
"security*"
"bluetooth*"
"wifi*"
"network*"
"tcc"
"notification*"
"accessibility*"
"universalaccess*"
"HIToolbox*"
"textinput*"
"TextInput*"
"keyboard*"
"Keyboard*"
"inputsource*"
"InputSource*"
"keylayout*"
"KeyLayout*"
"GlobalPreferences"
".GlobalPreferences"
"org.pqrs.Karabiner*"
)
# Apple apps that CAN be uninstalled (from App Store or developer.apple.com)
readonly APPLE_UNINSTALLABLE_APPS=(
"com.apple.dt.*" # Xcode, Instruments, FileMerge
"com.apple.FinalCut*" # Final Cut Pro
"com.apple.Motion"
"com.apple.Compressor"
"com.apple.logic*" # Logic Pro
"com.apple.garageband*" # GarageBand
"com.apple.iMovie"
"com.apple.iWork.*" # Pages, Numbers, Keynote
"com.apple.MainStage*"
"com.apple.server.*" # macOS Server
"com.apple.Playgrounds" # Swift Playgrounds
)
# Applications with sensitive data; protected during cleanup but removable
readonly DATA_PROTECTED_BUNDLES=(
# Input Methods (protected during cleanup, uninstall allowed)
"com.tencent.inputmethod.QQInput"
"com.sogou.inputmethod.*"
"com.baidu.inputmethod.*"
"com.googlecode.rimeime.*"
"im.rime.*"
"*.inputmethod"
"*.InputMethod"
"*IME"
# System Utilities & Cleanup
"com.nektony.*"
"com.macpaw.*"
"com.freemacsoft.AppCleaner"
"com.omnigroup.omnidisksweeper"
"com.daisydiskapp.*"
"com.tunabellysoftware.*"
"com.grandperspectiv.*"
"com.binaryfruit.*"
# Password Managers
"com.1password.*"
"com.agilebits.*"
"com.lastpass.*"
"com.dashlane.*"
"com.bitwarden.*"
"com.keepassx.*"
"org.keepassx.*"
"org.keepassxc.*"
"com.authy.*"
"com.yubico.*"
# IDEs & Editors
"com.jetbrains.*"
"JetBrains*"
"com.microsoft.VSCode"
"com.visualstudio.code.*"
"com.sublimetext.*"
"com.sublimehq.*"
"com.microsoft.VSCodeInsiders"
"com.apple.dt.Xcode"
"com.coteditor.CotEditor"
"com.macromates.TextMate"
"com.panic.Nova"
"abnerworks.Typora"
"com.uranusjr.macdown"
# AI & LLM Tools
"com.todesktop.*"
"Cursor"
"com.anthropic.claude*"
"Claude"
"com.openai.chat*"
"ChatGPT"
"com.ollama.ollama"
"Ollama"
"com.lmstudio.lmstudio"
"LM Studio"
"co.supertool.chatbox"
"page.jan.jan"
"com.huggingface.huggingchat"
"Gemini"
"com.perplexity.Perplexity"
"com.drawthings.DrawThings"
"com.divamgupta.diffusionbee"
"com.exafunction.windsurf"
"com.quora.poe.electron"
"chat.openai.com.*"
# Database Clients
"com.sequelpro.*"
"com.sequel-ace.*"
"com.tinyapp.*"
"com.dbeaver.*"
"com.navicat.*"
"com.mongodb.compass"
"com.redis.RedisInsight"
"com.pgadmin.pgadmin4"
"com.eggerapps.Sequel-Pro"
"com.valentina-db.Valentina-Studio"
"com.dbvis.DbVisualizer"
# API & Network Tools
"com.postmanlabs.mac"
"com.konghq.insomnia"
"com.CharlesProxy.*"
"com.proxyman.*"
"com.getpaw.*"
"com.luckymarmot.Paw"
"com.charlesproxy.charles"
"com.telerik.Fiddler"
"com.usebruno.app"
# Network Proxy & VPN Tools (Clash variants - use specific patterns to avoid false positives)
"com.clash.*"
"ClashX*"
"clash-*"
"Clash-*"
"*-clash"
"*-Clash"
"clash.*"
"Clash.*"
"clash_*"
"clashverge*"
"ClashVerge*"
"com.nssurge.surge-mac"
"*surge*"
"*Surge*"
"mihomo*"
"*openvpn*"
"*OpenVPN*"
"net.openvpn.*"
# Proxy Clients
"*ShadowsocksX-NG*"
"com.qiuyuzhou.*"
"*v2ray*"
"*V2Ray*"
"*v2box*"
"*V2Box*"
"*nekoray*"
"*sing-box*"
"*OneBox*"
"*hiddify*"
"*Hiddify*"
"*loon*"
"*Loon*"
"*quantumult*"
# Mesh & Corporate VPNs
"*tailscale*"
"io.tailscale.*"
"*zerotier*"
"com.zerotier.*"
"*1dot1dot1dot1*" # Cloudflare WARP
"*cloudflare*warp*"
# Commercial VPNs
"*nordvpn*"
"*expressvpn*"
"*protonvpn*"
"*surfshark*"
"*windscribe*"
"*mullvad*"
"*privateinternetaccess*"
# Screensaver & Wallpaper
"*Aerial*"
"*aerial*"
"*Fliqlo*"
"*fliqlo*"
# Git & Version Control
"com.github.GitHubDesktop"
"com.sublimemerge"
"com.torusknot.SourceTreeNotMAS"
"com.git-tower.Tower*"
"com.gitfox.GitFox"
"com.github.Gitify"
"com.fork.Fork"
"com.axosoft.gitkraken"
# Terminal & Shell
"com.googlecode.iterm2"
"net.kovidgoyal.kitty"
"io.alacritty"
"com.github.wez.wezterm"
"com.hyper.Hyper"
"com.mizage.divvy"
"com.fig.Fig"
"dev.warp.Warp-Stable"
"com.termius-dmg"
# Docker & Virtualization
"com.docker.docker"
"com.getutm.UTM"
"com.vmware.fusion"
"com.parallels.desktop.*"
"org.virtualbox.app.VirtualBox"
"com.vagrant.*"
"com.orbstack.OrbStack"
# System Monitoring
"com.bjango.istatmenus*"
"eu.exelban.Stats"
"com.monitorcontrol.*"
"com.bresink.system-toolkit.*"
"com.mediaatelier.MenuMeters"
"com.activity-indicator.app"
"net.cindori.sensei"
# Window Management
"com.macitbetter.*" # BetterTouchTool, BetterSnapTool
"com.hegenberg.*"
"com.manytricks.*" # Moom, Witch, etc.
"com.divisiblebyzero.*"
"com.koingdev.*"
"com.if.Amphetamine"
"com.lwouis.alt-tab-macos"
"net.matthewpalmer.Vanilla"
"com.lightheadsw.Caffeine"
"com.contextual.Contexts"
"com.amethyst.Amethyst"
"com.knollsoft.Rectangle"
"com.knollsoft.Hookshot"
"com.surteesstudios.Bartender"
"com.gaosun.eul"
"com.pointum.hazeover"
# Launcher & Automation
"com.runningwithcrayons.Alfred"
"com.raycast.macos"
"com.blacktree.Quicksilver"
"com.stairways.keyboardmaestro.*"
"com.manytricks.Butler"
"com.happenapps.Quitter"
"com.pilotmoon.scroll-reverser"
"org.pqrs.Karabiner-Elements"
"com.apple.Automator"
# Note-Taking
"com.bear-writer.*"
"com.typora.*"
"com.ulyssesapp.*"
"com.literatureandlatte.*"
"com.dayoneapp.*"
"notion.id"
"md.obsidian"
"com.logseq.logseq"
"com.evernote.Evernote"
"com.onenote.mac"
"com.omnigroup.OmniOutliner*"
"net.shinyfrog.bear"
"com.goodnotes.GoodNotes"
"com.marginnote.MarginNote*"
"com.roamresearch.*"
"com.reflect.ReflectApp"
"com.inkdrop.*"
# Design & Creative
"com.adobe.*"
"com.bohemiancoding.*"
"com.figma.*"
"com.framerx.*"
"com.zeplin.*"
"com.invisionapp.*"
"com.principle.*"
"com.pixelmatorteam.*"
"com.affinitydesigner.*"
"com.affinityphoto.*"
"com.affinitypublisher.*"
"com.linearity.curve"
"com.canva.CanvaDesktop"
"com.maxon.cinema4d"
"com.autodesk.*"
"com.sketchup.*"
# Communication
"com.tencent.xinWeChat"
"com.tencent.qq"
"com.alibaba.DingTalkMac"
"com.alibaba.AliLang.osx"
"com.alibaba.alilang3.osx.ShipIt"
"com.alibaba.AlilangMgr.QueryNetworkInfo"
"us.zoom.xos"
"com.microsoft.teams*"
"com.slack.Slack"
"com.hnc.Discord"
"app.legcord.Legcord"
"org.telegram.desktop"
"ru.keepcoder.Telegram"
"net.whatsapp.WhatsApp"
"com.skype.skype"
"com.cisco.webexmeetings"
"com.ringcentral.RingCentral"
"com.readdle.smartemail-Mac"
"com.airmail.*"
"com.postbox-inc.postbox"
"com.tinyspeck.slackmacgap"
# Task Management
"com.omnigroup.OmniFocus*"
"com.culturedcode.*"
"com.todoist.*"
"com.any.do.*"
"com.ticktick.*"
"com.microsoft.to-do"
"com.trello.trello"
"com.asana.nativeapp"
"com.clickup.*"
"com.monday.desktop"
"com.airtable.airtable"
"com.notion.id"
"com.linear.linear"
# File Transfer & Sync
"com.panic.transmit*"
"com.binarynights.ForkLift*"
"com.noodlesoft.Hazel"
"com.cyberduck.Cyberduck"
"io.filezilla.FileZilla"
"com.apple.Xcode.CloudDocuments"
"com.synology.*"
# Cloud Storage & Backup
"com.dropbox.*"
"com.getdropbox.*"
"*dropbox*"
"ws.agile.*"
"com.backblaze.*"
"*backblaze*"
"com.box.desktop*"
"*box.desktop*"
"com.microsoft.OneDrive*"
"com.microsoft.SyncReporter"
"*OneDrive*"
"com.google.GoogleDrive"
"com.google.keystone*"
"*GoogleDrive*"
"com.amazon.drive"
"com.apple.bird"
"com.apple.CloudDocs*"
"com.displaylink.*"
"com.fujitsu.pfu.ScanSnap*"
"com.citrix.*"
"org.xquartz.*"
"us.zoom.updater*"
"com.DigiDNA.iMazing*"
"com.shirtpocket.*"
"homebrew.mxcl.*"
# Screenshot & Recording
"com.cleanshot.*"
"com.xnipapp.xnip"
"com.reincubate.camo"
"com.tunabellysoftware.ScreenFloat"
"net.telestream.screenflow*"
"com.techsmith.snagit*"
"com.techsmith.camtasia*"
"com.obsidianapp.screenrecorder"
"com.kap.Kap"
"com.getkap.*"
"com.linebreak.CloudApp"
"com.droplr.droplr-mac"
# Media & Entertainment
"com.spotify.client"
"com.apple.Music"
"com.apple.podcasts"
"com.apple.BKAgentService"
"com.apple.iBooksX"
"com.apple.iBooks"
"com.blackmagic-design.*"
"com.colliderli.iina"
"org.videolan.vlc"
"io.mpv"
"tv.plex.player.desktop"
"com.netease.163music"
# Web Browsers
"Firefox"
"org.mozilla.*"
# License & App Stores
"com.paddle.Paddle*"
"com.setapp.DesktopClient"
"com.devmate.*"
"org.sparkle-project.Sparkle"
)
# Centralized check for critical system components (case-insensitive)
is_critical_system_component() {
local token="$1"
[[ -z "$token" ]] && return 1
local lower
lower=$(echo "$token" | LC_ALL=C tr '[:upper:]' '[:lower:]')
case "$lower" in
*backgroundtaskmanagement* | *loginitems* | *systempreferences* | *systemsettings* | *settings* | *preferences* | *controlcenter* | *biometrickit* | *sfl* | *tcc*)
return 0
;;
*)
return 1
;;
esac
}
# Legacy function - preserved for backward compatibility
# Use should_protect_from_uninstall() or should_protect_data() instead
readonly PRESERVED_BUNDLE_PATTERNS=("${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}")
# Check if bundle ID matches pattern (glob support)
bundle_matches_pattern() {
local bundle_id="$1"
local pattern="$2"
[[ -z "$pattern" ]] && return 1
# Use bash [[ ]] for glob pattern matching (works with variables in bash 3.2+)
# shellcheck disable=SC2053 # allow glob pattern matching
if [[ "$bundle_id" == $pattern ]]; then
return 0
fi
return 1
}
# Helper to build regex from array (Bash 3.2 compatible - no namerefs)
# $1: Variable name to store result
# $2...: Array elements (passed as expanded list)
build_regex_var() {
local var_name="$1"
shift
local regex=""
for pattern in "$@"; do
# Escape dots . -> \.
local p="${pattern//./\\.}"
# Convert * to .*
p="${p//\*/.*}"
# Start and end anchors
p="^${p}$"
if [[ -z "$regex" ]]; then
regex="$p"
else
regex="$regex|$p"
fi
done
eval "$var_name=\"\$regex\""
}
# Lazy-loaded regex (only built when needed)
APPLE_UNINSTALLABLE_REGEX=""
SYSTEM_CRITICAL_REGEX=""
SYSTEM_CRITICAL_FAST_REGEX=""
DATA_PROTECTED_REGEX=""
_ensure_uninstall_regex() {
if [[ -z "$SYSTEM_CRITICAL_REGEX" ]]; then
build_regex_var APPLE_UNINSTALLABLE_REGEX "${APPLE_UNINSTALLABLE_APPS[@]}"
build_regex_var SYSTEM_CRITICAL_REGEX "${SYSTEM_CRITICAL_BUNDLES[@]}"
fi
}
_ensure_data_protection_regex() {
if [[ -z "$SYSTEM_CRITICAL_FAST_REGEX" ]]; then
build_regex_var SYSTEM_CRITICAL_FAST_REGEX "${SYSTEM_CRITICAL_BUNDLES_FAST[@]}"
build_regex_var DATA_PROTECTED_REGEX "${DATA_PROTECTED_BUNDLES[@]}"
fi
}
# Check if application is a protected system component
should_protect_from_uninstall() {
local bundle_id="$1"
_ensure_uninstall_regex
if [[ "$bundle_id" =~ $APPLE_UNINSTALLABLE_REGEX ]]; then
return 1
fi
if [[ "$bundle_id" =~ $SYSTEM_CRITICAL_REGEX ]]; then
return 0
fi
return 1
}
# Check if application data should be protected during cleanup
should_protect_data() {
local bundle_id="$1"
case "$bundle_id" in
com.apple.* | loginwindow | dock | systempreferences | finder | safari)
return 0
;;
backgroundtaskmanagement* | keychain* | security* | bluetooth* | wifi* | network* | tcc)
return 0
;;
notification* | accessibility* | universalaccess* | HIToolbox*)
return 0
;;
*inputmethod* | *InputMethod* | *IME | textinput* | TextInput*)
return 0
;;
keyboard* | Keyboard* | inputsource* | InputSource* | keylayout* | KeyLayout*)
return 0
;;
GlobalPreferences | .GlobalPreferences | org.pqrs.Karabiner*)
return 0
;;
com.1password.* | com.agilebits.* | com.lastpass.* | com.dashlane.* | com.bitwarden.*)
return 0
;;
com.jetbrains.* | JetBrains* | com.microsoft.* | com.visualstudio.*)
return 0
;;
com.sublimetext.* | com.sublimehq.* | Cursor | Claude | ChatGPT | Ollama)
return 0
;;
com.nssurge.* | com.v2ray.* | com.clash.* | ClashX* | Surge* | Shadowrocket* | Quantumult*)
return 0
;;
clash-* | Clash-* | *-clash | *-Clash | clash.* | Clash.* | clash_* | clashverge* | ClashVerge*)
return 0
;;
com.docker.* | com.getpostman.* | com.insomnia.*)
return 0
;;
com.tencent.* | com.sogou.* | com.baidu.* | com.googlecode.* | im.rime.*)
# These might have wildcards, check detailed list
for pattern in "${DATA_PROTECTED_BUNDLES[@]}"; do
if bundle_matches_pattern "$bundle_id" "$pattern"; then
return 0
fi
done
return 1
;;
esac
# Most apps won't match, return early
return 1
}
# Check if a path is protected from deletion
# Centralized logic to protect system settings, control center, and critical apps
#
# In uninstall mode (MOLE_UNINSTALL_MODE=1), only system-critical components are protected.
# Data-protected apps (VPNs, dev tools, etc.) can be uninstalled when user explicitly chooses to.
#
# Args: $1 - path to check
# Returns: 0 if protected, 1 if safe to delete
should_protect_path() {
local path="$1"
[[ -z "$path" ]] && return 1
local path_lower
path_lower=$(echo "$path" | LC_ALL=C tr '[:upper:]' '[:lower:]')
# 1. Keyword-based matching for system components
# Protect System Settings, Preferences, Control Center, and related XPC services
# Also protect "Settings" (used in macOS Sequoia) and savedState files
if [[ "$path_lower" =~ systemsettings || "$path_lower" =~ systempreferences || "$path_lower" =~ controlcenter ]]; then
return 0
fi
# Additional check for com.apple.Settings (macOS Sequoia System Settings)
if [[ "$path_lower" =~ com\.apple\.settings ]]; then
return 0
fi
# Protect Notes cache (search index issues)
if [[ "$path_lower" =~ com\.apple\.notes ]]; then
return 0
fi
# 2. Protect caches critical for system UI rendering
# These caches are essential for modern macOS (Sonoma/Sequoia) system UI rendering
case "$path" in
# System Settings and Control Center caches (CRITICAL - prevents blank panel bug)
*com.apple.systempreferences.cache* | *com.apple.Settings.cache* | *com.apple.controlcenter.cache*)
return 0
;;
# Finder and Dock (system essential)
*com.apple.finder.cache* | *com.apple.dock.cache*)
return 0
;;
# System XPC services and sandboxed containers
*/Library/Containers/com.apple.Settings* | */Library/Containers/com.apple.SystemSettings* | */Library/Containers/com.apple.controlcenter*)
return 0
;;
*/Library/Group\ Containers/com.apple.systempreferences* | */Library/Group\ Containers/com.apple.Settings*)
return 0
;;
# Shared file lists for System Settings (macOS Sequoia) - Issue #136
*/com.apple.sharedfilelist/*com.apple.Settings* | */com.apple.sharedfilelist/*com.apple.SystemSettings* | */com.apple.sharedfilelist/*systempreferences*)
return 0
;;
esac
# 3. Extract bundle ID from sandbox paths
# Matches: .../Library/Containers/bundle.id/...
# Matches: .../Library/Group Containers/group.id/...
if [[ "$path" =~ /Library/Containers/([^/]+) ]] || [[ "$path" =~ /Library/Group\ Containers/([^/]+) ]]; then
local bundle_id="${BASH_REMATCH[1]}"
if should_protect_data "$bundle_id"; then
return 0
fi
fi
# 4. Check for specific hardcoded critical patterns
case "$path" in
*com.apple.Settings* | *com.apple.SystemSettings* | *com.apple.controlcenter* | *com.apple.finder* | *com.apple.dock*)
return 0
;;
esac
# 5. Protect critical preference files and user data
case "$path" in
*/Library/Preferences/com.apple.dock.plist | */Library/Preferences/com.apple.finder.plist)
return 0
;;
# Bluetooth and WiFi configurations
*/ByHost/com.apple.bluetooth.* | */ByHost/com.apple.wifi.*)
return 0
;;
# iCloud Drive - protect user's cloud synced data
*/Library/Mobile\ Documents* | */Mobile\ Documents*)
return 0
;;
esac
# 6. Match full path against protected patterns
# This catches things like /Users/tw93/Library/Caches/Claude when pattern is *Claude*
# In uninstall mode, only check system-critical bundles (user explicitly chose to uninstall)
if [[ "${MOLE_UNINSTALL_MODE:-0}" == "1" ]]; then
# Uninstall mode: first check if it's an uninstallable Apple app
for pattern in "${APPLE_UNINSTALLABLE_APPS[@]}"; do
if bundle_matches_pattern "$path" "$pattern"; then
return 1 # Can be uninstalled
fi
done
# Then check system-critical components
for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do
if bundle_matches_pattern "$path" "$pattern"; then
return 0
fi
done
else
# Normal mode (cleanup): protect both system-critical and data-protected bundles
for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do
if bundle_matches_pattern "$path" "$pattern"; then
return 0
fi
done
fi
# 7. Check if the filename itself matches any protected patterns
# Skip in uninstall mode - user explicitly chose to remove this app
if [[ "${MOLE_UNINSTALL_MODE:-0}" != "1" ]]; then
local filename
filename=$(basename "$path")
if should_protect_data "$filename"; then
return 0
fi
fi
return 1
}
# Check if a path is protected by whitelist patterns
# Args: $1 - path to check
# Returns: 0 if whitelisted, 1 if not
is_path_whitelisted() {
local target_path="$1"
[[ -z "$target_path" ]] && return 1
# Normalize path (remove trailing slash)
local normalized_target="${target_path%/}"
# Empty whitelist means nothing is protected
[[ ${#WHITELIST_PATTERNS[@]} -eq 0 ]] && return 1
for pattern in "${WHITELIST_PATTERNS[@]}"; do
# Pattern is already expanded/normalized in bin/clean.sh
local check_pattern="${pattern%/}"
local has_glob="false"
case "$check_pattern" in
*\** | *\?* | *\[*)
has_glob="true"
;;
esac
# Check for exact match or glob pattern match
# shellcheck disable=SC2053
if [[ "$normalized_target" == "$check_pattern" ]] ||
[[ "$normalized_target" == $check_pattern ]]; then
return 0
fi
# Check if target is a parent directory of a whitelisted path
# e.g., if pattern is /path/to/dir/subdir and target is /path/to/dir,
# the target should be protected to preserve its whitelisted children
if [[ "$check_pattern" == "$normalized_target"/* ]]; then
return 0
fi
# Check if target is a child of a whitelisted directory path
if [[ "$has_glob" == "false" && "$normalized_target" == "$check_pattern"/* ]]; then
return 0
fi
done
return 1
}
# Locate files associated with an application
find_app_files() {
local bundle_id="$1"
local app_name="$2"
# Early validation: require at least one valid identifier
# Skip scanning if both bundle_id and app_name are invalid
if [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] &&
[[ -z "$app_name" || ${#app_name} -lt 2 ]]; then
return 0 # Silent return to avoid invalid scanning
fi
local -a files_to_clean=()
# Normalize app name for matching - generate all common naming variants
# Apps use inconsistent naming: "Maestro Studio" vs "maestro-studio" vs "MaestroStudio"
# Note: Using tr for lowercase conversion (Bash 3.2 compatible, no ${var,,} support)
local nospace_name="${app_name// /}" # "Maestro Studio" -> "MaestroStudio"
local underscore_name="${app_name// /_}" # "Maestro Studio" -> "Maestro_Studio"
local hyphen_name="${app_name// /-}" # "Maestro Studio" -> "Maestro-Studio"
local lowercase_name=$(echo "$app_name" | tr '[:upper:]' '[:lower:]') # "Zed Nightly" -> "zed nightly"
local lowercase_nospace=$(echo "$nospace_name" | tr '[:upper:]' '[:lower:]') # "MaestroStudio" -> "maestrostudio"
local lowercase_hyphen=$(echo "$hyphen_name" | tr '[:upper:]' '[:lower:]') # "Maestro-Studio" -> "maestro-studio"
local lowercase_underscore=$(echo "$underscore_name" | tr '[:upper:]' '[:lower:]') # "Maestro_Studio" -> "maestro_studio"
# Extract base name by removing common version/channel suffixes
# "Zed Nightly" -> "Zed", "Firefox Developer Edition" -> "Firefox"
local base_name="$app_name"
local version_suffixes="Nightly|Beta|Alpha|Dev|Canary|Preview|Insider|Edge|Stable|Release|RC|LTS"
version_suffixes+="|Developer Edition|Technology Preview"
if [[ "$app_name" =~ ^(.+)[[:space:]]+(${version_suffixes})$ ]]; then
base_name="${BASH_REMATCH[1]}"
fi
local base_lowercase=$(echo "$base_name" | tr '[:upper:]' '[:lower:]') # "Zed" -> "zed"
# Standard path patterns for user-level files
local -a user_patterns=(
"$HOME/Library/Application Support/$app_name"
"$HOME/Library/Application Support/$bundle_id"
"$HOME/Library/Caches/$bundle_id"
"$HOME/Library/Caches/$app_name"
"$HOME/Library/Logs/$app_name"
"$HOME/Library/Logs/$bundle_id"
"$HOME/Library/Application Support/CrashReporter/$app_name"
"$HOME/Library/Saved Application State/$bundle_id.savedState"
"$HOME/Library/Containers/$bundle_id"
"$HOME/Library/WebKit/$bundle_id"
"$HOME/Library/WebKit/com.apple.WebKit.WebContent/$bundle_id"
"$HOME/Library/HTTPStorages/$bundle_id"
"$HOME/Library/Cookies/$bundle_id.binarycookies"
"$HOME/Library/LaunchAgents/$bundle_id.plist"
"$HOME/Library/Application Scripts/$bundle_id"
"$HOME/Library/Services/$app_name.workflow"
"$HOME/Library/QuickLook/$app_name.qlgenerator"
"$HOME/Library/Internet Plug-Ins/$app_name.plugin"
"$HOME/Library/Audio/Plug-Ins/Components/$app_name.component"
"$HOME/Library/Audio/Plug-Ins/VST/$app_name.vst"
"$HOME/Library/Audio/Plug-Ins/VST3/$app_name.vst3"
"$HOME/Library/Audio/Plug-Ins/Digidesign/$app_name.dpm"
"$HOME/Library/PreferencePanes/$app_name.prefPane"
"$HOME/Library/Input Methods/$app_name.app"
"$HOME/Library/Input Methods/$bundle_id.app"
"$HOME/Library/Screen Savers/$app_name.saver"
"$HOME/Library/Frameworks/$app_name.framework"
"$HOME/Library/Autosave Information/$bundle_id"
"$HOME/Library/Contextual Menu Items/$app_name.plugin"
"$HOME/Library/Spotlight/$app_name.mdimporter"
"$HOME/Library/ColorPickers/$app_name.colorPicker"
"$HOME/Library/Workflows/$app_name.workflow"
"$HOME/.config/$app_name"
"$HOME/.local/share/$app_name"
"$HOME/.$app_name"
"$HOME/.$app_name"rc
)
# Add all naming variants to cover inconsistent app directory naming
# Issue #377: Apps create directories with various naming conventions
if [[ ${#app_name} -gt 3 && "$app_name" =~ [[:space:]] ]]; then
user_patterns+=(
# Compound naming (MaestroStudio, Maestro_Studio, Maestro-Studio)
"$HOME/Library/Application Support/$nospace_name"
"$HOME/Library/Caches/$nospace_name"
"$HOME/Library/Logs/$nospace_name"
"$HOME/Library/Application Support/$underscore_name"
"$HOME/Library/Application Support/$hyphen_name"
# Lowercase variants (maestrostudio, maestro-studio, maestro_studio)
"$HOME/.config/$lowercase_nospace"
"$HOME/.config/$lowercase_hyphen"
"$HOME/.config/$lowercase_underscore"
"$HOME/.local/share/$lowercase_nospace"
"$HOME/.local/share/$lowercase_hyphen"
"$HOME/.local/share/$lowercase_underscore"
)
fi
# Add base name variants for versioned apps (e.g., "Zed Nightly" -> check for "zed")
if [[ "$base_name" != "$app_name" && ${#base_name} -gt 2 ]]; then
user_patterns+=(
"$HOME/Library/Application Support/$base_name"
"$HOME/Library/Caches/$base_name"
"$HOME/Library/Logs/$base_name"
"$HOME/.config/$base_lowercase"
"$HOME/.local/share/$base_lowercase"
"$HOME/.$base_lowercase"
)
fi
# Process standard patterns
for p in "${user_patterns[@]}"; do
local expanded_path="${p/#\~/$HOME}"
# Skip if path doesn't exist
[[ ! -e "$expanded_path" ]] && continue
# Safety check: Skip if path ends with a common directory name (indicates empty app_name/bundle_id)
# This prevents deletion of entire Library subdirectories when bundle_id is empty
case "$expanded_path" in
*/Library/Application\ Support | */Library/Application\ Support/ | \
*/Library/Caches | */Library/Caches/ | \
*/Library/Logs | */Library/Logs/ | \
*/Library/Containers | */Library/Containers/ | \
*/Library/WebKit | */Library/WebKit/ | \
*/Library/HTTPStorages | */Library/HTTPStorages/ | \
*/Library/Application\ Scripts | */Library/Application\ Scripts/ | \
*/Library/Autosave\ Information | */Library/Autosave\ Information/ | \
*/Library/Group\ Containers | */Library/Group\ Containers/)
continue
;;
esac
files_to_clean+=("$expanded_path")
done
# Handle Preferences and ByHost variants (only if bundle_id is valid)
if [[ -n "$bundle_id" && "$bundle_id" != "unknown" && ${#bundle_id} -gt 3 ]]; then
[[ -f ~/Library/Preferences/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id.plist")
[[ -d ~/Library/Preferences/ByHost ]] && while IFS= read -r -d '' pref; do
files_to_clean+=("$pref")
done < <(command find ~/Library/Preferences/ByHost -maxdepth 1 \( -name "$bundle_id*.plist" \) -print0 2> /dev/null)
# Group Containers (special handling)
if [[ -d ~/Library/Group\ Containers ]]; then
while IFS= read -r -d '' container; do
files_to_clean+=("$container")
done < <(command find ~/Library/Group\ Containers -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null)
fi
fi
# Launch Agents by name (special handling)
# Note: LaunchDaemons are system-level and handled in find_app_system_files()
# Minimum 5-char threshold prevents false positives (e.g., "Time" matching system agents)
# Short-name apps (e.g., Zoom, Arc) are still cleaned via bundle_id matching above
# Security: Common words are excluded to prevent matching unrelated plist files
if [[ ${#app_name} -ge 5 ]] && [[ -d ~/Library/LaunchAgents ]]; then
# Skip common words that could match many unrelated LaunchAgents
# These are either generic terms or names that overlap with system/common utilities
local common_words="Music|Notes|Photos|Finder|Safari|Preview|Calendar|Contacts|Messages|Reminders|Clock|Weather|Stocks|Books|News|Podcasts|Voice|Files|Store|System|Helper|Agent|Daemon|Service|Update|Sync|Backup|Cloud|Manager|Monitor|Server|Client|Worker|Runner|Launcher|Driver|Plugin|Extension|Widget|Utility"
if [[ "$app_name" =~ ^($common_words)$ ]]; then
debug_log "Skipping LaunchAgent name search for common word: $app_name"
else
while IFS= read -r -d '' plist; do
local plist_name=$(basename "$plist")
# Skip Apple's LaunchAgents
if [[ "$plist_name" =~ ^com\.apple\. ]]; then
continue
fi
files_to_clean+=("$plist")
done < <(command find ~/Library/LaunchAgents -maxdepth 1 -name "*$app_name*.plist" -print0 2> /dev/null)
fi
fi
# Handle specialized toolchains and development environments
# 1. DevEco-Studio (Huawei)
if [[ "$app_name" =~ DevEco|deveco ]] || [[ "$bundle_id" =~ huawei.*deveco ]]; then
for d in ~/DevEcoStudioProjects ~/DevEco-Studio ~/Library/Application\ Support/Huawei ~/Library/Caches/Huawei ~/Library/Logs/Huawei ~/Library/Huawei ~/Huawei ~/HarmonyOS ~/.huawei ~/.ohos; do
[[ -d "$d" ]] && files_to_clean+=("$d")
done
fi
# 2. Android Studio (Google)
if [[ "$app_name" =~ Android.*Studio|android.*studio ]] || [[ "$bundle_id" =~ google.*android.*studio|jetbrains.*android ]]; then
for d in ~/AndroidStudioProjects ~/Library/Android ~/.android; do
[[ -d "$d" ]] && files_to_clean+=("$d")
done
[[ -d ~/Library/Application\ Support/Google ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find ~/Library/Application\ Support/Google -maxdepth 1 -name "AndroidStudio*" -print0 2> /dev/null)
fi
# 3. Xcode (Apple)
if [[ "$app_name" =~ Xcode|xcode ]] || [[ "$bundle_id" =~ apple.*xcode ]]; then
[[ -d ~/Library/Developer ]] && files_to_clean+=("$HOME/Library/Developer")
[[ -d ~/.Xcode ]] && files_to_clean+=("$HOME/.Xcode")
fi
# 4. JetBrains (IDE settings)
if [[ "$bundle_id" =~ jetbrains ]] || [[ "$app_name" =~ IntelliJ|PyCharm|WebStorm|GoLand|RubyMine|PhpStorm|CLion|DataGrip|Rider ]]; then
for base in ~/Library/Application\ Support/JetBrains ~/Library/Caches/JetBrains ~/Library/Logs/JetBrains; do
[[ -d "$base" ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find "$base" -maxdepth 1 -name "${app_name}*" -print0 2> /dev/null)
done
fi
# 5. Unity / Unreal / Godot
[[ "$app_name" =~ Unity|unity ]] && [[ -d ~/Library/Unity ]] && files_to_clean+=("$HOME/Library/Unity")
[[ "$app_name" =~ Unreal|unreal ]] && [[ -d ~/Library/Application\ Support/Epic ]] && files_to_clean+=("$HOME/Library/Application Support/Epic")
[[ "$app_name" =~ Godot|godot ]] && [[ -d ~/Library/Application\ Support/Godot ]] && files_to_clean+=("$HOME/Library/Application Support/Godot")
# 6. Tools
[[ "$bundle_id" =~ microsoft.*vscode ]] && [[ -d ~/.vscode ]] && files_to_clean+=("$HOME/.vscode")
[[ "$app_name" =~ Docker ]] && [[ -d ~/.docker ]] && files_to_clean+=("$HOME/.docker")
# Output results
if [[ ${#files_to_clean[@]} -gt 0 ]]; then
printf '%s\n' "${files_to_clean[@]}"
fi
return 0
}
# Locate system-level application files
find_app_system_files() {
local bundle_id="$1"
local app_name="$2"
local -a system_files=()
# Generate all naming variants (same as find_app_files for consistency)
local nospace_name="${app_name// /}"
local underscore_name="${app_name// /_}"
local hyphen_name="${app_name// /-}"
local lowercase_hyphen=$(echo "$hyphen_name" | tr '[:upper:]' '[:lower:]')
# Standard system path patterns
local -a system_patterns=(
"/Library/Application Support/$app_name"
"/Library/Application Support/$bundle_id"
"/Library/LaunchAgents/$bundle_id.plist"
"/Library/LaunchDaemons/$bundle_id.plist"
"/Library/Preferences/$bundle_id.plist"
"/Library/Receipts/$bundle_id.bom"
"/Library/Receipts/$bundle_id.plist"
"/Library/Frameworks/$app_name.framework"
"/Library/Internet Plug-Ins/$app_name.plugin"
"/Library/Input Methods/$app_name.app"
"/Library/Input Methods/$bundle_id.app"
"/Library/Audio/Plug-Ins/Components/$app_name.component"
"/Library/Audio/Plug-Ins/VST/$app_name.vst"
"/Library/Audio/Plug-Ins/VST3/$app_name.vst3"
"/Library/Audio/Plug-Ins/Digidesign/$app_name.dpm"
"/Library/QuickLook/$app_name.qlgenerator"
"/Library/PreferencePanes/$app_name.prefPane"
"/Library/Screen Savers/$app_name.saver"
"/Library/Caches/$bundle_id"
"/Library/Caches/$app_name"
)
# Add all naming variants for apps with spaces in name
if [[ ${#app_name} -gt 3 && "$app_name" =~ [[:space:]] ]]; then
system_patterns+=(
"/Library/Application Support/$nospace_name"
"/Library/Caches/$nospace_name"
"/Library/Logs/$nospace_name"
"/Library/Application Support/$underscore_name"
"/Library/Application Support/$hyphen_name"
"/Library/Caches/$hyphen_name"
"/Library/Caches/$lowercase_hyphen"
)
fi
# Process patterns
for p in "${system_patterns[@]}"; do
[[ ! -e "$p" ]] && continue
# Safety check: Skip if path ends with a common directory name (indicates empty app_name/bundle_id)
case "$p" in
/Library/Application\ Support | /Library/Application\ Support/ | \
/Library/Caches | /Library/Caches/ | \
/Library/Logs | /Library/Logs/)
continue
;;
esac
system_files+=("$p")
done
# System LaunchAgents/LaunchDaemons by name
if [[ ${#app_name} -gt 3 ]]; then
for base in /Library/LaunchAgents /Library/LaunchDaemons; do
[[ -d "$base" ]] && while IFS= read -r -d '' plist; do
system_files+=("$plist")
done < <(command find "$base" -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null)
done
fi
# Privileged Helper Tools and Receipts (special handling)
# Only search with bundle_id if it's valid (not empty and not "unknown")
if [[ -n "$bundle_id" && "$bundle_id" != "unknown" && ${#bundle_id} -gt 3 ]]; then
[[ -d /Library/PrivilegedHelperTools ]] && while IFS= read -r -d '' helper; do
system_files+=("$helper")
done < <(command find /Library/PrivilegedHelperTools -maxdepth 1 \( -name "$bundle_id*" \) -print0 2> /dev/null)
[[ -d /private/var/db/receipts ]] && while IFS= read -r -d '' receipt; do
system_files+=("$receipt")
done < <(command find /private/var/db/receipts -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null)
fi
local receipt_files=""
receipt_files=$(find_app_receipt_files "$bundle_id")
local combined_files=""
if [[ ${#system_files[@]} -gt 0 ]]; then
combined_files=$(printf '%s\n' "${system_files[@]}")
fi
if [[ -n "$receipt_files" ]]; then
if [[ -n "$combined_files" ]]; then
combined_files+=$'\n'
fi
combined_files+="$receipt_files"
fi
if [[ -n "$combined_files" ]]; then
printf '%s\n' "$combined_files" | sort -u
fi
}
# Locate files using installation receipts (BOM)
find_app_receipt_files() {
local bundle_id="$1"
# Skip if no bundle ID
[[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0
# Validate bundle_id format to prevent wildcard injection
# Only allow alphanumeric characters, dots, hyphens, and underscores
if [[ ! "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]]; then
debug_log "Invalid bundle_id format: $bundle_id"
return 0
fi
local -a receipt_files=()
local -a bom_files=()
# Find receipts matching the bundle ID
# Usually in /var/db/receipts/
if [[ -d /private/var/db/receipts ]]; then
while IFS= read -r -d '' bom; do
bom_files+=("$bom")
done < <(find /private/var/db/receipts -maxdepth 1 -name "${bundle_id}*.bom" -print0 2> /dev/null)
fi
# Process bom files if any found
if [[ ${#bom_files[@]} -gt 0 ]]; then
for bom_file in "${bom_files[@]}"; do
[[ ! -f "$bom_file" ]] && continue
# Parse bom file
# lsbom -f: file paths only
# -s: suppress output (convert to text)
local bom_content
bom_content=$(lsbom -f -s "$bom_file" 2> /dev/null)
while IFS= read -r file_path; do
# Standardize path (remove leading dot)
local clean_path="${file_path#.}"
# Ensure absolute path
if [[ "$clean_path" != /* ]]; then
clean_path="/$clean_path"
fi
# Path traversal protection: reject paths containing ..
if [[ "$clean_path" =~ \.\. ]]; then
debug_log "Rejected path traversal in BOM: $clean_path"
continue
fi
# Normalize path (remove duplicate slashes)
clean_path=$(tr -s "/" <<< "$clean_path")
# ------------------------------------------------------------------------
# Safety check: restrict removal to trusted paths
# ------------------------------------------------------------------------
local is_safe=false
# Whitelisted prefixes (exclude /Users, /usr, /opt)
case "$clean_path" in
/Applications/*) is_safe=true ;;
/Library/Application\ Support/*) is_safe=true ;;
/Library/Caches/*) is_safe=true ;;
/Library/Logs/*) is_safe=true ;;
/Library/Preferences/*) is_safe=true ;;
/Library/LaunchAgents/*) is_safe=true ;;
/Library/LaunchDaemons/*) is_safe=true ;;
/Library/PrivilegedHelperTools/*) is_safe=true ;;
/Library/Extensions/*) is_safe=false ;;
*) is_safe=false ;;
esac
# Hard blocks
case "$clean_path" in
/System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/*) is_safe=false ;;
esac
if [[ "$is_safe" == "true" && -e "$clean_path" ]]; then
# Skip top-level directories
if [[ "$clean_path" == "/Applications" || "$clean_path" == "/Library" ]]; then
continue
fi
if declare -f should_protect_path > /dev/null 2>&1; then
if should_protect_path "$clean_path"; then
continue
fi
fi
receipt_files+=("$clean_path")
fi
done <<< "$bom_content"
done
fi
if [[ ${#receipt_files[@]} -gt 0 ]]; then
printf '%s\n' "${receipt_files[@]}"
fi
}
# Terminate a running application
force_kill_app() {
# Gracefully terminates or force-kills an application
local app_name="$1"
local app_path="${2:-""}"
# Get the executable name from bundle if app_path is provided
local exec_name=""
if [[ -n "$app_path" && -e "$app_path/Contents/Info.plist" ]]; then
exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "")
fi
# Use executable name for precise matching, fallback to app name
local match_pattern="${exec_name:-$app_name}"
# Check if process is running using exact match only
if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then
return 0
fi
# Try graceful termination first
pkill -x "$match_pattern" 2> /dev/null || true
sleep 2
# Check again after graceful kill
if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then
return 0
fi
# Force kill if still running
pkill -9 -x "$match_pattern" 2> /dev/null || true
sleep 2
# If still running and sudo is available, try with sudo
if pgrep -x "$match_pattern" > /dev/null 2>&1; then
if sudo -n true 2> /dev/null; then
sudo pkill -9 -x "$match_pattern" 2> /dev/null || true
sleep 2
fi
fi
# Final check with longer timeout for stubborn processes
local retries=3
while [[ $retries -gt 0 ]]; do
if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then
return 0
fi
sleep 1
((retries--))
done
# Still running after all attempts
pgrep -x "$match_pattern" > /dev/null 2>&1 && return 1 || return 0
}
# Note: calculate_total_size() is defined in lib/core/file_ops.sh