mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 16:14:44 +00:00
433 lines
13 KiB
Bash
Executable File
433 lines
13 KiB
Bash
Executable File
#!/bin/bash
|
||
# Mole - Common Functions Library
|
||
# Shared utilities and functions for all modules
|
||
|
||
set -euo pipefail
|
||
|
||
# Color definitions (readonly for safety)
|
||
readonly ESC=$'\033'
|
||
readonly GREEN="${ESC}[0;32m"
|
||
readonly BLUE="${ESC}[0;34m"
|
||
readonly YELLOW="${ESC}[1;33m"
|
||
readonly PURPLE="${ESC}[0;35m"
|
||
readonly RED="${ESC}[0;31m"
|
||
readonly GRAY="${ESC}[0;90m"
|
||
readonly NC="${ESC}[0m"
|
||
|
||
# Logging configuration
|
||
readonly LOG_FILE="${HOME}/.config/mole/mole.log"
|
||
readonly LOG_MAX_SIZE_DEFAULT=1048576 # 1MB
|
||
|
||
# Ensure log directory exists
|
||
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
|
||
|
||
# Enhanced logging functions with file logging support
|
||
log_info() {
|
||
rotate_log
|
||
echo -e "${BLUE}$1${NC}"
|
||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] INFO: $1" >> "$LOG_FILE" 2>/dev/null || true
|
||
}
|
||
|
||
log_success() {
|
||
rotate_log
|
||
echo -e " ${GREEN}✓${NC} $1"
|
||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] SUCCESS: $1" >> "$LOG_FILE" 2>/dev/null || true
|
||
}
|
||
|
||
log_warning() {
|
||
rotate_log
|
||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARNING: $1" >> "$LOG_FILE" 2>/dev/null || true
|
||
}
|
||
|
||
log_error() {
|
||
rotate_log
|
||
echo -e "${RED}❌ $1${NC}" >&2
|
||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1" >> "$LOG_FILE" 2>/dev/null || true
|
||
}
|
||
|
||
log_header() {
|
||
rotate_log
|
||
echo -e "\n${PURPLE}▶ $1${NC}"
|
||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] SECTION: $1" >> "$LOG_FILE" 2>/dev/null || true
|
||
}
|
||
|
||
# Log file maintenance
|
||
rotate_log() {
|
||
local max_size="${MOLE_MAX_LOG_SIZE:-$LOG_MAX_SIZE_DEFAULT}"
|
||
if [[ -f "$LOG_FILE" ]] && [[ $(stat -f%z "$LOG_FILE" 2>/dev/null || echo 0) -gt "$max_size" ]]; then
|
||
mv "$LOG_FILE" "${LOG_FILE}.old" 2>/dev/null || true
|
||
touch "$LOG_FILE" 2>/dev/null || true
|
||
fi
|
||
}
|
||
|
||
# System detection
|
||
detect_architecture() {
|
||
if [[ "$(uname -m)" == "arm64" ]]; then
|
||
echo "Apple Silicon"
|
||
else
|
||
echo "Intel"
|
||
fi
|
||
}
|
||
|
||
get_free_space() {
|
||
df -h / | awk 'NR==2 {print $4}'
|
||
}
|
||
|
||
# Common UI functions
|
||
clear_screen() {
|
||
printf '\033[2J\033[H'
|
||
}
|
||
|
||
hide_cursor() {
|
||
printf '\033[?25l'
|
||
}
|
||
|
||
show_cursor() {
|
||
printf '\033[?25h'
|
||
}
|
||
|
||
# Keyboard input handling (simple and robust)
|
||
read_key() {
|
||
local key
|
||
IFS= read -r -s -n 1 key || return 1
|
||
|
||
# Empty = ENTER (some terminals)
|
||
if [[ -z "$key" ]]; then
|
||
echo "ENTER"; return 0
|
||
fi
|
||
|
||
case "$key" in
|
||
$'\n'|$'\r') echo "ENTER" ;;
|
||
' ') echo "SPACE" ;;
|
||
'q'|'Q') echo "QUIT" ;;
|
||
'a'|'A') echo "ALL" ;;
|
||
'n'|'N') echo "NONE" ;;
|
||
'?') echo "HELP" ;;
|
||
$'\x7f'|$'\b') echo "BACKSPACE" ;; # Support Backspace
|
||
$'\x1b')
|
||
# ESC sequence handling. Allow slightly longer window so we don't misinterpret slow terminals.
|
||
local next third
|
||
if IFS= read -r -s -n 1 -t 0.15 next 2>/dev/null; then
|
||
if [[ "$next" == "[" ]]; then
|
||
if IFS= read -r -s -n 1 -t 0.15 third 2>/dev/null; then
|
||
case "$third" in
|
||
'A') echo "UP" ;;
|
||
'B') echo "DOWN" ;;
|
||
'C') echo "RIGHT" ;;
|
||
'D') echo "LEFT" ;;
|
||
*) echo "OTHER" ;;
|
||
esac
|
||
else
|
||
# ESC [ then timeout – treat as OTHER to ignore
|
||
echo "OTHER"
|
||
fi
|
||
else
|
||
# ESC + something (Alt modified key) → ignore as OTHER
|
||
echo "OTHER"
|
||
fi
|
||
else
|
||
# Bare ESC alone: instead of quitting directly, emit OTHER so user doesn't exit accidentally
|
||
echo "OTHER"
|
||
fi
|
||
;;
|
||
*) echo "OTHER" ;;
|
||
esac
|
||
}
|
||
# Drain any pending input bytes (used to swallow rapid trackpad scroll sequences)
|
||
drain_pending_input() {
|
||
while IFS= read -r -s -t 0 -n 1 _; do :; done
|
||
}
|
||
|
||
|
||
# Menu display helper
|
||
show_menu_option() {
|
||
local number="$1"
|
||
local text="$2"
|
||
local selected="$3"
|
||
|
||
if [[ "$selected" == "true" ]]; then
|
||
echo -e "${BLUE}▶ $number. $text${NC}"
|
||
else
|
||
echo " $number. $text"
|
||
fi
|
||
}
|
||
|
||
# Error handling
|
||
handle_error() {
|
||
local message="$1"
|
||
local exit_code="${2:-1}"
|
||
|
||
log_error "$message"
|
||
exit "$exit_code"
|
||
}
|
||
|
||
# File size utilities
|
||
get_human_size() {
|
||
local path="$1"
|
||
if [[ ! -e "$path" ]]; then
|
||
echo "N/A"
|
||
return 1
|
||
fi
|
||
du -sh "$path" 2>/dev/null | cut -f1 || echo "N/A"
|
||
}
|
||
|
||
# Convert bytes to human readable format
|
||
bytes_to_human() {
|
||
local bytes="$1"
|
||
if [[ ! "$bytes" =~ ^[0-9]+$ ]]; then
|
||
echo "0B"
|
||
return 1
|
||
fi
|
||
|
||
if ((bytes >= 1073741824)); then # >= 1GB
|
||
echo "$bytes" | awk '{printf "%.2fGB", $1/1073741824}'
|
||
elif ((bytes >= 1048576)); then # >= 1MB
|
||
echo "$bytes" | awk '{printf "%.1fMB", $1/1048576}'
|
||
elif ((bytes >= 1024)); then # >= 1KB
|
||
echo "$bytes" | awk '{printf "%.0fKB", $1/1024}'
|
||
else
|
||
echo "${bytes}B"
|
||
fi
|
||
}
|
||
|
||
# Calculate directory size in bytes
|
||
get_directory_size_bytes() {
|
||
local path="$1"
|
||
if [[ ! -d "$path" ]]; then
|
||
echo "0"
|
||
return 1
|
||
fi
|
||
du -sk "$path" 2>/dev/null | cut -f1 | awk '{print $1 * 1024}' || echo "0"
|
||
}
|
||
|
||
|
||
# Permission checks
|
||
check_sudo() {
|
||
if ! sudo -n true 2>/dev/null; then
|
||
return 1
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
request_sudo() {
|
||
echo "This operation requires administrator privileges."
|
||
echo -n "Please enter your password: "
|
||
read -s password
|
||
echo
|
||
if echo "$password" | sudo -S true 2>/dev/null; then
|
||
return 0
|
||
else
|
||
log_error "Invalid password or cancelled"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# Load basic configuration
|
||
load_config() {
|
||
MOLE_MAX_LOG_SIZE="${MOLE_MAX_LOG_SIZE:-1048576}"
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
# Initialize configuration on sourcing
|
||
load_config
|
||
|
||
# ============================================================================
|
||
# App Management Functions
|
||
# ============================================================================
|
||
|
||
# Essential system and critical app patterns that should never be removed
|
||
readonly PRESERVED_BUNDLE_PATTERNS=(
|
||
# System essentials
|
||
"com.apple.*"
|
||
"loginwindow"
|
||
"dock"
|
||
"systempreferences"
|
||
"finder"
|
||
"safari"
|
||
"keychain*"
|
||
"security*"
|
||
"bluetooth*"
|
||
"wifi*"
|
||
"network*"
|
||
"tcc"
|
||
"notification*"
|
||
"accessibility*"
|
||
"universalaccess*"
|
||
"HIToolbox*"
|
||
"textinput*"
|
||
"TextInput*"
|
||
"keyboard*"
|
||
"Keyboard*"
|
||
"inputsource*"
|
||
"InputSource*"
|
||
"keylayout*"
|
||
"KeyLayout*"
|
||
"GlobalPreferences"
|
||
".GlobalPreferences"
|
||
|
||
# Input methods (critical for international users)
|
||
# Specific input method bundles
|
||
"com.tencent.inputmethod.QQInput"
|
||
"com.sogou.inputmethod.*"
|
||
"com.baidu.inputmethod.*"
|
||
"com.apple.inputmethod.*"
|
||
"com.googlecode.rimeime.*"
|
||
"im.rime.*"
|
||
"org.pqrs.Karabiner*"
|
||
# Generic patterns (more conservative)
|
||
"*.inputmethod"
|
||
"*.InputMethod"
|
||
"*IME"
|
||
# Keep system input services safe
|
||
"com.apple.inputsource*"
|
||
"com.apple.TextInputMenuAgent"
|
||
"com.apple.TextInputSwitcher"
|
||
|
||
# Cleanup and system tools (avoid infinite loops and preserve licenses)
|
||
"com.nektony.*" # App Cleaner & Uninstaller
|
||
"com.macpaw.*" # CleanMyMac, CleanMaster
|
||
"com.freemacsoft.AppCleaner" # AppCleaner
|
||
"com.omnigroup.omnidisksweeper" # OmniDiskSweeper
|
||
"com.daisydiskapp.*" # DaisyDisk
|
||
"com.tunabellysoftware.*" # Disk Utility apps
|
||
"com.grandperspectiv.*" # GrandPerspective
|
||
"com.binaryfruit.*" # FusionCast
|
||
"com.CharlesProxy.*" # Charles Proxy (paid)
|
||
"com.proxyman.*" # Proxyman (paid)
|
||
"com.getpaw.*" # Paw (paid)
|
||
|
||
# Security and password managers (critical data)
|
||
"com.1password.*" # 1Password
|
||
"com.agilebits.*" # 1Password legacy
|
||
"com.lastpass.*" # LastPass
|
||
"com.dashlane.*" # Dashlane
|
||
"com.bitwarden.*" # Bitwarden
|
||
"com.keepassx.*" # KeePassXC
|
||
|
||
# Development tools (licenses and settings)
|
||
"com.jetbrains.*" # JetBrains IDEs (paid licenses)
|
||
"com.sublimetext.*" # Sublime Text (paid)
|
||
"com.panic.transmit*" # Transmit (paid)
|
||
"com.sequelpro.*" # Database tools
|
||
"com.sequel-ace.*"
|
||
"com.tinyapp.*" # TablePlus (paid)
|
||
|
||
# Design tools (expensive licenses)
|
||
"com.adobe.*" # Adobe Creative Suite
|
||
"com.bohemiancoding.*" # Sketch
|
||
"com.figma.*" # Figma
|
||
"com.framerx.*" # Framer
|
||
"com.zeplin.*" # Zeplin
|
||
"com.invisionapp.*" # InVision
|
||
"com.principle.*" # Principle
|
||
|
||
# Productivity (important data and licenses)
|
||
"com.omnigroup.*" # OmniFocus, OmniGraffle, etc.
|
||
"com.culturedcode.*" # Things
|
||
"com.todoist.*" # Todoist
|
||
"com.bear-writer.*" # Bear
|
||
"com.typora.*" # Typora
|
||
"com.ulyssesapp.*" # Ulysses
|
||
"com.literatureandlatte.*" # Scrivener
|
||
"com.dayoneapp.*" # Day One
|
||
|
||
# Media and entertainment (licenses)
|
||
"com.spotify.client" # Spotify (premium accounts)
|
||
"com.apple.FinalCutPro" # Final Cut Pro
|
||
"com.apple.Motion" # Motion
|
||
"com.apple.Compressor" # Compressor
|
||
"com.blackmagic-design.*" # DaVinci Resolve
|
||
"com.pixelmatorteam.*" # Pixelmator
|
||
)
|
||
|
||
# Check if bundle should be preserved (system/critical apps)
|
||
should_preserve_bundle() {
|
||
local bundle_id="$1"
|
||
for pattern in "${PRESERVED_BUNDLE_PATTERNS[@]}"; do
|
||
if [[ "$bundle_id" == $pattern ]]; then
|
||
return 0
|
||
fi
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# Find and list app-related files (consolidated from duplicates)
|
||
find_app_files() {
|
||
local bundle_id="$1"
|
||
local app_name="$2"
|
||
local -a files_to_clean=()
|
||
|
||
# Application Support
|
||
[[ -d ~/Library/Application\ Support/"$app_name" ]] && files_to_clean+=("$HOME/Library/Application Support/$app_name")
|
||
[[ -d ~/Library/Application\ Support/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Application Support/$bundle_id")
|
||
|
||
# Caches
|
||
[[ -d ~/Library/Caches/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Caches/$bundle_id")
|
||
|
||
# Preferences
|
||
[[ -f ~/Library/Preferences/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id.plist")
|
||
|
||
# Logs
|
||
[[ -d ~/Library/Logs/"$app_name" ]] && files_to_clean+=("$HOME/Library/Logs/$app_name")
|
||
[[ -d ~/Library/Logs/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Logs/$bundle_id")
|
||
|
||
# Saved Application State
|
||
[[ -d ~/Library/Saved\ Application\ State/"$bundle_id".savedState ]] && files_to_clean+=("$HOME/Library/Saved Application State/$bundle_id.savedState")
|
||
|
||
# Containers (sandboxed apps)
|
||
[[ -d ~/Library/Containers/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Containers/$bundle_id")
|
||
|
||
# Group Containers
|
||
while IFS= read -r -d '' container; do
|
||
files_to_clean+=("$container")
|
||
done < <(find ~/Library/Group\ Containers -name "*$bundle_id*" -type d -print0 2>/dev/null)
|
||
|
||
# Only print if array has elements to avoid unbound variable error
|
||
if [[ ${#files_to_clean[@]} -gt 0 ]]; then
|
||
printf '%s\n' "${files_to_clean[@]}"
|
||
fi
|
||
}
|
||
|
||
# Calculate total size of files (consolidated from duplicates)
|
||
calculate_total_size() {
|
||
local files="$1"
|
||
local total_kb=0
|
||
|
||
while IFS= read -r file; do
|
||
if [[ -n "$file" && -e "$file" ]]; then
|
||
local size_kb=$(du -sk "$file" 2>/dev/null | awk '{print $1}' || echo "0")
|
||
((total_kb += size_kb))
|
||
fi
|
||
done <<< "$files"
|
||
|
||
echo "$total_kb"
|
||
}
|
||
|
||
# Get normalized brand name (bash 3.2 compatible using case statement)
|
||
get_brand_name() {
|
||
local name="$1"
|
||
|
||
# Brand name mapping for better user recognition
|
||
case "$name" in
|
||
"qiyimac"|"爱奇艺") echo "iQiyi" ;;
|
||
"wechat"|"微信") echo "WeChat" ;;
|
||
"QQ") echo "QQ" ;;
|
||
"VooV Meeting"|"腾讯会议") echo "VooV Meeting" ;;
|
||
"dingtalk"|"钉钉") echo "DingTalk" ;;
|
||
"NeteaseMusic"|"网易云音乐") echo "NetEase Music" ;;
|
||
"BaiduNetdisk"|"百度网盘") echo "Baidu NetDisk" ;;
|
||
"alipay"|"支付宝") echo "Alipay" ;;
|
||
"taobao"|"淘宝") echo "Taobao" ;;
|
||
"futunn"|"富途牛牛") echo "Futu NiuNiu" ;;
|
||
"tencent lemon"|"Tencent Lemon Cleaner") echo "Tencent Lemon" ;;
|
||
"keynote"|"Keynote") echo "Keynote" ;;
|
||
"pages"|"Pages") echo "Pages" ;;
|
||
"numbers"|"Numbers") echo "Numbers" ;;
|
||
*) echo "$name" ;; # Return original if no mapping found
|
||
esac
|
||
}
|