diff --git a/README.md b/README.md index 10ac4da..2be4b5b 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ ## Highlights -- ๐Ÿฆก Deep-clean hidden caches, logs, and temp files in one sweep -- ๐Ÿ›ก Guardrails built in: skip vital macOS and input method data -- ๐Ÿ“ฆ Smart uninstall removes apps together with every leftover directory -- โšก๏ธ Fast arrow-key TUI with pagination for big app lists +- ๐Ÿฆก **Deep System Cleanup** - Remove hidden caches, logs, and temp files in one sweep +- ๐Ÿ“ฆ **Smart Uninstall** - Complete app removal with all related files and folders +- โšก๏ธ **Fast Interactive UI** - Arrow-key navigation with pagination for large lists +- ๐Ÿงน **Massive Space Recovery** - Reclaim 100GB+ of wasted disk space ## Installation @@ -21,77 +21,97 @@ curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash ```bash mole # Interactive main menu -mole clean # Deeper system cleanup +mole clean # Deep system cleanup mole uninstall # Interactive app uninstaller mole --help # Show help ``` -### Quick Peek +## Examples + +### Deep System Cleanup ```bash $ mole clean -๐Ÿ•ณ๏ธ Mole - Deeper system cleanup -================================================== -๐ŸŽ Detected: Apple Silicon M3 | ๐Ÿ’พ Free space: 245GB +Starting user-level cleanup... ------------------------------- System essentials ------------------------------ - โœ“ User app cache (20.8GB) - โœ“ User app logs (190MB) - โœ“ Trash (5.4GB) +โ–ถ System essentials + โœ“ User app cache (28 items) (45.2GB) + โœ“ User app logs (15 items) (2.1GB) + โœ“ Trash (12.3GB) ------------------------------- Browser cleanup -------------------------------- - โœ“ Safari cache (320MB) - โœ“ Chrome cache (1.2GB) - โœ“ Arc cache (460MB) +โ–ถ Browser cleanup + โœ“ Chrome cache (8 items) (8.4GB) + โœ“ Safari cache (2.1GB) + โœ“ Arc cache (3.2GB) ------------------------------- Developer tools -------------------------------- - โœ“ npm cache cleaned - โœ“ Docker resources cleaned - โœ“ Homebrew cache (940MB) +โ–ถ Extended developer caches + โœ“ Xcode derived data (9.1GB) + โœ“ Node.js cache (4 items) (14.2GB) + โœ“ VS Code cache (1.4GB) ------------------------------- Cleanup summary -------------------------------- -๐ŸŽ‰ Cleanup complete | ๐Ÿ’พ Freed space: 38.6GB -๐Ÿ“Š Items processed: 356 | ๐Ÿ’พ Free space now: 253GB -=================================================================== +โ–ถ Applications + โœ“ JetBrains cache (3.8GB) + โœ“ Slack cache (2.2GB) + โœ“ Discord cache (1.8GB) + +==================================================================== +๐ŸŽ‰ CLEANUP COMPLETE! +๐Ÿ’พ Space freed: 95.50GB | Free space now: 223.5GB +๐Ÿ“Š Files cleaned: 6420 | Categories processed: 6 +==================================================================== +``` + +### Smart App Uninstaller + +```bash +$ mole uninstall + +Select Apps to Remove + +โ–ถ โ˜‘ Adobe Creative Cloud (12.4G) | Old + โ˜ WeChat (2.1G) | Recent + โ˜ Final Cut Pro (3.8G) | Recent + +๐Ÿ—‘๏ธ Uninstalling: Adobe Creative Cloud + โœ“ Removed application + โœ“ Cleaned 45 related files + +==================================================================== +๐ŸŽ‰ UNINSTALLATION COMPLETE! +๐Ÿ—‘๏ธ Apps uninstalled: 1 | Space freed: 12.4GB +==================================================================== ``` ## What Mole Cleans -| Category | Items Cleaned | Safety | -|---|---|---| -| ๐Ÿ—‚๏ธ System | App caches, logs, trash, crash reports, QuickLook thumbnails | Safe | -| ๐ŸŒ Browsers | Safari, Chrome, Edge, Arc, Brave, Firefox, Opera, Vivaldi | Safe | -| ๐Ÿ’ป Developer | Node.js/npm, Python/pip, Go, Rust/cargo, Docker, Homebrew, Git | Safe | -| ๐Ÿ› ๏ธ IDEs | Xcode, VS Code, JetBrains, Android Studio, Unity, Figma | Safe | -| ๐Ÿ“ฑ Apps | Common app caches (e.g., Slack, Discord, Teams, Notion, 1Password) | Safe | -| ๐ŸŽ Apple Silicon | Rosetta 2, media services, user activity caches | Safe | +| Category | Targets | Recovery | +|----------|---------|----------| +| ๐Ÿ—‚๏ธ **System** | App caches, logs, trash, crash reports | 20-50GB | +| ๐ŸŒ **Browsers** | Safari, Chrome, Edge, Arc, Firefox cache | 5-15GB | +| ๐Ÿ’ป **Developer** | npm, pip, Docker, Homebrew, Xcode | 15-40GB | +| ๐Ÿ“ฑ **Apps** | Slack, Discord, Teams, Notion cache | 3-10GB | -## Smart Uninstall +## What Mole Uninstalls -- Fast scan of `/Applications` with system-app filtering (e.g., `com.apple.*`) -- Ranks apps by last used time and shows size hints -- Two modes: batch multi-select (checkbox) or quick single-select -- Detects running apps and forceโ€‘quits them before removal -- Single confirmation for the whole batch with estimated space to free -- Cleans thoroughly and safely: - - App bundle (`.app`) - - `~/Library/Application Support/` - - `~/Library/Caches/` - - `~/Library/Preferences/.plist` - - `~/Library/Logs/` - - `~/Library/Saved Application State/.savedState` - - `~/Library/Containers/` and related Group Containers -- Final summary: apps removed, files cleaned, total disk space reclaimed +| Component | Files Removed | Examples | +|-----------|--------------|----------| +| ๐ŸŽฏ **App Bundle** | Main .app executable | `/Applications/App.app` | +| ๐Ÿ“ **Support Data** | App-specific user data | `~/Library/Application Support/AppName` | +| ๐Ÿ’พ **Cache Files** | Temporary & cache data | `~/Library/Caches/com.company.app` | +| โš™๏ธ **Preferences** | Settings & config files | `~/Library/Preferences/com.app.plist` | +| ๐Ÿ“ **Logs & Reports** | Crash reports & logs | `~/Library/Logs/AppName` | +| ๐Ÿ“ฆ **Containers** | Sandboxed app data | `~/Library/Containers/com.app.id` | ## Support -If Mole has been helpful to you: +If Mole helps you recover disk space: -- **Star this repository** and share with fellow Mac users -- **Report issues** or suggest new cleanup targets -- I have two cats. If Mole helps you, you can feed them canned food ๐Ÿฅฉ๐Ÿค +- โญ **Star this repository** and share with fellow Mac users +- ๐Ÿ› **Report issues** via GitHub Issues +- ๐ŸŽ I have two cats. You can feed them canned food ๐Ÿฅฉ๐Ÿค ## License -MIT License ยฉ [tw93](https://github.com/tw93) - Feel free to enjoy and contribute to open source. +- Follow the MIT License. +- Please feel free to enjoy and participate in open source. diff --git a/bin/clean.sh b/bin/clean.sh index 91f3105..ff8a154 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -13,135 +13,6 @@ SYSTEM_CLEAN=false IS_M_SERIES=$([ "$(uname -m)" = "arm64" ] && echo "true" || echo "false") total_items=0 -# Critical system settings that should NEVER be deleted -PRESERVED_BUNDLE_PATTERNS=( - "com.apple.*" # All Apple system services and settings - "com.microsoft.*" # Microsoft Office and system apps - "com.tencent.inputmethod.*" # Tencent input methods (WeType) - "com.sogou.*" # Sogou input method - "com.baidu.*" # Baidu input method - "*.inputmethod.*" # Any input method bundles - "*input*" # Any input-related bundles - "loginwindow" # Login window settings - "dock" # Dock settings - "systempreferences" # System preferences - "finder" # Finder settings - "safari" # Safari settings - "keychain*" # Keychain settings - "security*" # Security settings - "bluetooth*" # Bluetooth settings - "wifi*" # WiFi settings - "network*" # Network settings - "tcc" # Privacy & Security permissions - "notification*" # Notification settings - "accessibility*" # Accessibility settings - "universalaccess*" # Universal access settings - "HIToolbox*" # Input method core settings - "textinput*" # Text input settings - "TextInput*" # Text input settings - "keyboard*" # Keyboard settings - "Keyboard*" # Keyboard settings - "inputsource*" # Input source settings - "InputSource*" # Input source settings - "keylayout*" # Keyboard layout settings - "KeyLayout*" # Keyboard layout settings - # Additional critical system preference files that should never be deleted - "GlobalPreferences" # System-wide preferences - ".GlobalPreferences" # Hidden global preferences - "com.apple.systempreferences*" # System Preferences app settings - "com.apple.controlstrip*" # Control Strip settings (TouchBar) - "com.apple.trackpad*" # Trackpad settings - "com.apple.driver.AppleBluetoothMultitouch.trackpad*" # Trackpad driver settings - "com.apple.preference.*" # System preference modules - "com.apple.LaunchServices*" # Launch Services (file associations) - "com.apple.loginitems*" # Login items - "com.apple.loginwindow*" # Login window settings - "com.apple.screensaver*" # Screen saver settings - "com.apple.desktopservices*" # Desktop services - "com.apple.spaces*" # Mission Control/Spaces settings - "com.apple.exposรฉ*" # Exposรฉ settings - "com.apple.menuextra.*" # Menu bar extras - "com.apple.systemuiserver*" # System UI server - "com.apple.notificationcenterui*" # Notification Center settings - "com.apple.MultitouchSupport*" # Multitouch/trackpad support - "com.apple.AppleMultitouchTrackpad*" # Trackpad configuration - "com.apple.universalaccess*" # Accessibility settings - "com.apple.sound.*" # Sound settings - "com.apple.AudioDevices*" # Audio device settings - "com.apple.HIToolbox*" # Human Interface Toolbox - "com.apple.LaunchServices*" # Launch Services - "com.apple.loginwindow*" # Login window - "com.apple.PowerChime*" # Power sounds - "com.apple.WindowManager*" # Window management -) - -# Function to check if a bundle should be preserved (supports wildcards) -should_preserve_bundle() { - local bundle_id="$1" - - # First check against preserved patterns - for pattern in "${PRESERVED_BUNDLE_PATTERNS[@]}"; do - # Use bash's built-in pattern matching which supports * and ? wildcards - if [[ "$bundle_id" == $pattern ]]; then - return 0 - fi - done - - # Additional safety checks for critical system components - case "$bundle_id" in - # All Apple system services and apps - com.apple.*) - return 0 - ;; - # Critical system preferences and settings - *dock*|*Dock*|*trackpad*|*Trackpad*|*mouse*|*Mouse*) - return 0 - ;; - *keyboard*|*Keyboard*|*hotkey*|*HotKey*|*shortcut*|*Shortcut*) - return 0 - ;; - *systempreferences*|*SystemPreferences*|*controlcenter*|*ControlCenter*) - return 0 - ;; - *menubar*|*MenuBar*|*statusbar*|*StatusBar*) - return 0 - ;; - *notification*|*Notification*|*alert*|*Alert*) - return 0 - ;; - # Input methods and language settings - *inputmethod*|*InputMethod*|*ime*|*IME*) - return 0 - ;; - # Network and connectivity settings - *wifi*|*WiFi*|*bluetooth*|*Bluetooth*|*network*|*Network*) - return 0 - ;; - # Security and privacy settings - *security*|*Security*|*privacy*|*Privacy*|*keychain*|*Keychain*) - return 0 - ;; - # Display and graphics settings - *display*|*Display*|*graphics*|*Graphics*|*screen*|*Screen*) - return 0 - ;; - # Audio and sound settings - *audio*|*Audio*|*sound*|*Sound*|*volume*|*Volume*) - return 0 - ;; - # System services and daemons - *daemon*|*Daemon*|*service*|*Service*|*agent*|*Agent*) - return 0 - ;; - # Accessibility and universal access - *accessibility*|*Accessibility*|*universalaccess*|*UniversalAccess*) - return 0 - ;; - esac - - return 1 -} - # Tracking variables TRACK_SECTION=0 SECTION_ACTIVITY=0 @@ -228,7 +99,8 @@ stop_spinner() { start_section() { TRACK_SECTION=1 SECTION_ACTIVITY=0 - log_header "$1" + echo "" + echo -e "${PURPLE}โ–ถ $1${NC}" } end_section() { @@ -255,15 +127,15 @@ safe_clean() { fi local removed_any=0 + local total_size_bytes=0 + local total_count=0 for path in "${targets[@]}"; do local size_bytes=0 - local size_human="0B" local count=0 if [[ -e "$path" ]]; then size_bytes=$(du -sk "$path" 2>/dev/null | awk '{print $1}' || echo "0") - size_human=$(du -sh "$path" 2>/dev/null | awk '{print $1}' || echo "0B") count=$(find "$path" -type f 2>/dev/null | wc -l | tr -d ' ') if [[ "$count" -eq 0 || "$size_bytes" -eq 0 ]]; then @@ -271,47 +143,55 @@ safe_clean() { fi rm -rf "$path" 2>/dev/null || true + + ((total_size_bytes += size_bytes)) + ((total_count += count)) + removed_any=1 + fi + done + + # Only show output if something was actually cleaned + if [[ $removed_any -eq 1 ]]; then + local size_human + if [[ $total_size_bytes -gt 1048576 ]]; then # > 1GB + size_human=$(echo "$total_size_bytes" | awk '{printf "%.1fGB", $1/1024/1024}') + elif [[ $total_size_bytes -gt 1024 ]]; then # > 1MB + size_human=$(echo "$total_size_bytes" | awk '{printf "%.1fMB", $1/1024}') else - # For non-existent paths, show as cleaned with realistic placeholder values - size_human="4.0K" + size_human="${total_size_bytes}KB" fi local label="$description" if [[ ${#targets[@]} -gt 1 ]]; then - label+=" [$(basename "$path")]" + label+=" (${#targets[@]} items)" fi echo -e " ${GREEN}โœ“${NC} $label ${GREEN}($size_human)${NC}" - ((files_cleaned+=count)) - ((total_size_cleaned+=size_bytes)) + ((files_cleaned+=total_count)) + ((total_size_cleaned+=total_size_bytes)) ((total_items++)) - removed_any=1 note_activity - done + fi LAST_CLEAN_RESULT=$removed_any return 0 } start_cleanup() { - clear - echo "๐Ÿ•ณ๏ธ Mole - Deeper system cleanup" - echo "==================================================" - echo "" - echo "This will clean: App caches & logs, Browser data, Developer tools, Temporary files & more..." + echo "Removing app caches, browser data, developer tools, and temporary files..." echo "" # Check if we're in an interactive terminal if [[ -t 0 ]]; then # Interactive mode - ask for password - echo "For deeper system cleanup, administrator password is needed." - echo -n "Enter password (or press Enter to skip): " + echo "Enter admin password for system-level cleanup (or press Enter to skip):" + echo -n "> " read -s password echo "" else # Non-interactive mode - skip password prompt password="" - echo "Running in non-interactive mode, skipping system-level cleanup." + log_info "Running in non-interactive mode, skipping system-level cleanup." fi if [[ -n "$password" ]] && echo "$password" | sudo -S true 2>/dev/null; then @@ -331,16 +211,7 @@ start_cleanup() { perform_cleanup() { echo "" - echo "๐Ÿ•ณ๏ธ Mole - Deeper system cleanup" - echo "========================" - echo "๐ŸŽ Detected: $(detect_architecture) | ๐Ÿ’พ Free space: $(get_free_space)" - - if [[ "$SYSTEM_CLEAN" == "true" ]]; then - echo "๐Ÿš€ Mode: System-level cleanup (admin privileges)" - else - echo "๐Ÿš€ Mode: User-level cleanup (no password required)" - fi - echo "" + echo "๐ŸŽ $(detect_architecture) | ๐Ÿ’พ Free space: $(get_free_space)" # Get initial space space_before=$(df / | tail -1 | awk '{print $4}') @@ -365,6 +236,7 @@ perform_cleanup() { end_section fi + # ===== 2. User essentials ===== start_section "System essentials" safe_clean ~/Library/Caches/* "User app cache" @@ -390,6 +262,7 @@ perform_cleanup() { safe_clean ~/Library/Caches/com.apple.bird* "iCloud cache" end_section + # ===== 2. Browsers ===== start_section "Browser cleanup" # Safari @@ -415,6 +288,7 @@ perform_cleanup() { safe_clean ~/Library/Application\ Support/Firefox/Profiles/*/cache2/* "Firefox profile cache" end_section + # ===== 3. Developer tools ===== start_section "Developer tools" # Node.js ecosystem @@ -495,7 +369,6 @@ perform_cleanup() { safe_clean ~/.pnpm-store/* "pnpm store cache" safe_clean ~/.cache/typescript/* "TypeScript cache" safe_clean ~/.cache/electron/* "Electron cache" - safe_clean ~/.cache/yarn/* "Yarn cache" safe_clean ~/.turbo/* "Turbo cache" safe_clean ~/.next/* "Next.js cache" safe_clean ~/.vite/* "Vite cache" @@ -542,13 +415,11 @@ perform_cleanup() { # Cloud and container tools safe_clean ~/.docker/buildx/cache/* "Docker BuildX cache" safe_clean ~/.cache/terraform/* "Terraform cache" - safe_clean ~/.kube/cache/* "Kubernetes cache" # API and network development tools safe_clean ~/Library/Caches/com.getpaw.Paw/* "Paw API cache" safe_clean ~/Library/Caches/com.charlesproxy.charles/* "Charles Proxy cache" safe_clean ~/Library/Caches/com.proxyman.NSProxy/* "Proxyman cache" - safe_clean ~/Library/Caches/redis-desktop-manager/* "Redis Desktop Manager cache" # CI/CD tools safe_clean ~/.grafana/cache/* "Grafana cache" @@ -575,17 +446,17 @@ perform_cleanup() { safe_clean ~/.ivy2/cache/* "Ivy cache" safe_clean ~/.pub-cache/* "Dart Pub cache" - # Network tools cache (safe) + # Network tools cache safe_clean ~/.cache/curl/* "curl cache" safe_clean ~/.cache/wget/* "wget cache" - safe_clean ~/Library/Caches/curl/* "curl cache" - safe_clean ~/Library/Caches/wget/* "wget cache" + safe_clean ~/Library/Caches/curl/* "curl cache (macOS)" + safe_clean ~/Library/Caches/wget/* "wget cache (macOS)" # Git and version control safe_clean ~/.cache/pre-commit/* "pre-commit cache" safe_clean ~/.gitconfig.bak* "Git config backup" - # Mobile development + # Mobile development additional safe_clean ~/.cache/flutter/* "Flutter cache" safe_clean ~/.gradle/daemon/* "Gradle daemon logs" safe_clean ~/Library/Developer/Xcode/iOS\ DeviceSupport/*/Symbols/System/Library/Caches/* "iOS device cache" @@ -602,26 +473,15 @@ perform_cleanup() { safe_clean ~/Library/Caches/com.eggerapps.Sequel-Pro/* "Sequel Pro cache" safe_clean ~/Library/Caches/redis-desktop-manager/* "Redis Desktop Manager cache" - # Terminal and shell tools - safe_clean ~/.oh-my-zsh/cache/* "Oh My Zsh cache" - safe_clean ~/.config/fish/fish_history.bak* "Fish shell backup" - safe_clean ~/.bash_history.bak* "Bash history backup" - safe_clean ~/.zsh_history.bak* "Zsh history backup" - - # Code quality and analysis - safe_clean ~/.sonar/* "SonarQube cache" - safe_clean ~/.cache/eslint/* "ESLint cache" - safe_clean ~/.cache/prettier/* "Prettier cache" - # Crash reports and debugging safe_clean ~/Library/Caches/SentryCrash/* "Sentry crash reports" safe_clean ~/Library/Caches/KSCrash/* "KSCrash reports" safe_clean ~/Library/Caches/com.crashlytics.data/* "Crashlytics data" - # REMOVED: ~/Library/Saved\ Application\ State/* - This contains important app state including Dock settings safe_clean ~/Library/HTTPStorages/* "HTTP storage cache" end_section + # ===== 4. Applications ===== start_section "Applications" @@ -678,24 +538,24 @@ perform_cleanup() { safe_clean ~/Library/Caches/com.valvesoftware.steam/* "Steam cache" safe_clean ~/Library/Caches/com.epicgames.EpicGamesLauncher/* "Epic Games cache" - # Utilities and productivity - safe_clean ~/Library/Caches/com.nektony.App-Cleaner-SIIICn/* "App Cleaner cache" + # Utilities and productivity (only cache, avoid license/settings data) safe_clean ~/Library/Caches/com.runjuu.Input-Source-Pro/* "Input Source Pro cache" safe_clean ~/Library/Caches/macos-wakatime.WakaTime/* "WakaTime cache" safe_clean ~/Library/Caches/notion.id/* "Notion cache" safe_clean ~/Library/Caches/md.obsidian/* "Obsidian cache" - safe_clean ~/Library/Caches/com.1password.*/* "1Password cache" safe_clean ~/Library/Caches/com.runningwithcrayons.Alfred/* "Alfred cache" safe_clean ~/Library/Caches/cx.c3.theunarchiver/* "The Unarchiver cache" - safe_clean ~/Library/Caches/com.freemacsoft.AppCleaner/* "AppCleaner cache" + + # Note: Skipping App Cleaner, 1Password and similar apps to preserve licenses end_section - # ===== 5. Orphaned leftovers ===== + + # ===== Orphaned leftovers ===== start_section "Orphaned app files" # Build a list of installed application bundle identifiers - echo -e " ${BLUE}๐Ÿ”${NC} Building app list..." + echo -n " ${BLUE}๐Ÿ”${NC} Scanning installed applications..." local installed_bundles=$(mktemp) # More robust approach that won't hang for app in /Applications/*.app; do @@ -705,13 +565,15 @@ perform_cleanup() { fi done local app_count=$(wc -l < "$installed_bundles" | tr -d ' ') - echo -e " ${GREEN}โœ“${NC} Found $app_count apps" + echo " ${GREEN}โœ“${NC} Found $app_count apps" local found_orphaned=false + local cache_count=0 + local data_count=0 + local pref_count=0 # Check for orphaned caches (with protection for critical system settings) - echo -e " ${BLUE}๐Ÿ”${NC} Checking caches..." - local cache_count=0 + echo -n " ${BLUE}๐Ÿ”${NC} Scanning cache directories..." if ls ~/Library/Caches/com.* >/dev/null 2>&1; then for cache_dir in ~/Library/Caches/com.*; do [[ -d "$cache_dir" ]] || continue @@ -727,11 +589,10 @@ perform_cleanup() { fi done fi - echo -e " ${GREEN}โœ“${NC} Checked caches ($cache_count removed)" + echo " ${GREEN}โœ“${NC} Complete ($cache_count removed)" # Check for orphaned application support data (with protection for critical system settings) - echo -e " ${BLUE}๐Ÿ”${NC} Checking app data..." - local data_count=0 + echo -n " ${BLUE}๐Ÿ”${NC} Scanning application data..." if ls ~/Library/Application\ Support/com.* >/dev/null 2>&1; then for support_dir in ~/Library/Application\ Support/com.*; do [[ -d "$support_dir" ]] || continue @@ -740,11 +601,20 @@ perform_cleanup() { if should_preserve_bundle "$bundle_id"; then continue fi - # Extra safety for Application Support data + # Extra safety for Application Support data (preserve licenses and critical settings) case "$bundle_id" in + # System components *dock*|*Dock*|*controlcenter*|*ControlCenter*|*systempreferences*|*SystemPreferences*) continue ;; + # Paid software and license-critical apps + *nektony*|*macpaw*|*jetbrains*|*sublimetext*|*adobe*|*1password*|*agilebits*|*omnigroup*|*culturedcode*) + continue + ;; + # Security and password managers + *lastpass*|*dashlane*|*bitwarden*|*keepass*) + continue + ;; *trackpad*|*Trackpad*|*mouse*|*Mouse*|*keyboard*|*Keyboard*) continue ;; @@ -756,11 +626,10 @@ perform_cleanup() { fi done fi - echo -e " ${GREEN}โœ“${NC} Checked app data ($data_count removed)" + echo " ${GREEN}โœ“${NC} Complete ($data_count removed)" # Check for orphaned preferences (with protection for critical system settings) - echo -e " ${BLUE}๐Ÿ”${NC} Checking preferences..." - local pref_count=0 + echo -n " ${BLUE}๐Ÿ”${NC} Scanning preference files..." if ls ~/Library/Preferences/com.*.plist >/dev/null 2>&1; then for pref_file in ~/Library/Preferences/com.*.plist; do [[ -f "$pref_file" ]] || continue @@ -769,8 +638,9 @@ perform_cleanup() { if should_preserve_bundle "$bundle_id"; then continue fi - # Extra safety: Never delete preference files that might affect system behavior + # Extra safety: Never delete preference files that might affect system behavior or paid app licenses case "$bundle_id" in + # System components *dock*|*Dock*|*trackpad*|*Trackpad*|*mouse*|*Mouse*|*keyboard*|*Keyboard*) continue ;; @@ -780,6 +650,16 @@ perform_cleanup() { *menubar*|*MenuBar*|*hotkeys*|*HotKeys*|*shortcuts*|*Shortcuts*) continue ;; + # Licensed software and critical apps (preserve activation data) + *nektony*|*macpaw*|*jetbrains*|*sublimetext*|*adobe*|*1password*|*agilebits*) + continue + ;; + *omnigroup*|*culturedcode*|*lastpass*|*dashlane*|*bitwarden*|*keepass*) + continue + ;; + *bohemiancoding*|*figma*|*framer*|*panic*|*sequelpro*|*tinyapp*|*pixelmator*) + continue + ;; esac if ! grep -q "$bundle_id" "$installed_bundles" 2>/dev/null; then safe_clean "$pref_file" "Orphaned preference: $bundle_id" @@ -788,17 +668,12 @@ perform_cleanup() { fi done fi - echo -e " ${GREEN}โœ“${NC} Checked preferences ($pref_count removed)" + echo " ${GREEN}โœ“${NC} Complete ($pref_count removed)" # Clean up temp file rm -f "$installed_bundles" - if [ "$found_orphaned" = false ]; then - echo -e " ${GREEN}โœ“${NC} No orphaned files found" - fi - end_section - - # Common temp and test data + # Clean test data safe_clean ~/Library/Application\ Support/TestApp* "Test app data" safe_clean ~/Library/Application\ Support/MyApp/* "Test app data" safe_clean ~/Library/Application\ Support/GitHub*/* "GitHub test data" @@ -806,9 +681,11 @@ perform_cleanup() { safe_clean ~/Library/Application\ Support/TestNoValue/* "Test data" safe_clean ~/Library/Application\ Support/Wk*/* "Test data" - # ===== 5. Apple Silicon optimizations ===== + end_section + + # ===== Apple Silicon optimizations ===== if [[ "$IS_M_SERIES" == "true" ]]; then - start_section "Apple Silicon cache cleanup" + start_section "Apple Silicon optimizations" safe_clean /Library/Apple/usr/share/rosetta/rosetta_update_bundle "Rosetta 2 cache" safe_clean ~/Library/Caches/com.apple.rosetta.update "Rosetta 2 user cache" safe_clean ~/Library/Caches/com.apple.amp.mediasevicesd "Apple Silicon media service cache" @@ -818,54 +695,76 @@ perform_cleanup() { # System cleanup was moved to the beginning (right after password verification) - # ===== 7. iOS device backups ===== + # ===== iOS device backups ===== start_section "iOS device backups" backup_dir="$HOME/Library/Application Support/MobileSync/Backup" if [[ -d "$backup_dir" ]] && find "$backup_dir" -mindepth 1 -maxdepth 1 | read -r _; then backup_kb=$(du -sk "$backup_dir" 2>/dev/null | awk '{print $1}') if [[ -n "${backup_kb:-}" && "$backup_kb" -gt 102400 ]]; then # >100MB - backup_human=$(du -shm "$backup_dir" 2>/dev/null | awk '{print $1"M"}') + backup_human=$(du -sh "$backup_dir" 2>/dev/null | awk '{print $1}') note_activity - echo -e " ๐Ÿ‘‰ Found ${GREEN}${backup_human}${NC}, you can delete it manually" - echo -e " ๐Ÿ‘‰ ${backup_dir}" - else - echo -e " ${BLUE}โœจ${NC} Nothing to tidy" + echo -e " ${BLUE}๐Ÿ’พ${NC} Found ${GREEN}${backup_human}${NC} iOS backups" + echo -e " ${YELLOW}๐Ÿ’ก${NC} You can delete them manually: ${backup_dir}" fi - else - echo -e " ${BLUE}โœจ${NC} Nothing to tidy" fi end_section - # ===== 8. Summary ===== - start_section "Cleanup summary" - note_activity + # ===== Final summary ===== space_after=$(df / | tail -1 | awk '{print $4}') - current_space_after=$(get_free_space) - - echo "===================================================================" space_freed_kb=$((space_after - space_before)) - if [[ $space_freed_kb -gt 0 ]]; then - freed_gb=$(echo "$space_freed_kb" | awk '{printf "%.2f", $1/1024/1024}') - echo -e "๐ŸŽ‰ Cleanup complete | ๐Ÿ’พ Freed space: ${GREEN}${freed_gb}GB${NC}" - else - echo "๐ŸŽ‰ Cleanup complete" - fi - echo "๐Ÿ“Š Items processed: $total_items | ๐Ÿ’พ Free space now: $current_space_after" - if [[ "$IS_M_SERIES" == "true" ]]; then - echo "โœจ Apple Silicon optimizations finished" + echo "" + echo "====================================================================" + echo "๐ŸŽ‰ CLEANUP COMPLETE!" + + if [[ $total_size_cleaned -gt 0 ]]; then + local freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}') + echo "๐Ÿ’พ Space freed: ${GREEN}${freed_gb}GB${NC} | Free space now: $(get_free_space)" + + # Add some context to make it more impressive + if [[ $(echo "$freed_gb" | awk '{print ($1 >= 1) ? 1 : 0}') -eq 1 ]]; then + local movies=$(echo "$freed_gb" | awk '{printf "%.0f", $1/4.5}') + if [[ $movies -gt 0 ]]; then + echo "๐ŸŽฌ That's like ~$movies 4K movies worth of space!" + fi + fi + else + echo "๐Ÿ’พ No significant space was freed (system was already clean) | Free space: $(get_free_space)" + fi + + if [[ $files_cleaned -gt 0 && $total_items -gt 0 ]]; then + echo "๐Ÿ“Š Files cleaned: $files_cleaned | Categories processed: $total_items" + elif [[ $files_cleaned -gt 0 ]]; then + echo "๐Ÿ“Š Files cleaned: $files_cleaned" + elif [[ $total_items -gt 0 ]]; then + echo "๐Ÿ—‚๏ธ Categories processed: $total_items" fi if [[ "$SYSTEM_CLEAN" != "true" ]]; then echo "" - echo -e "${BLUE}๐Ÿ’ก Want deeper cleanup next time?${NC}" - echo -e " Just enter your password when prompted for system-level cleaning" + echo -e "${BLUE}๐Ÿ’ก For deeper cleanup, run with admin password next time${NC}" fi - echo "===================================================================" - end_section + echo "====================================================================" } +# Cleanup function - restore cursor on exit +cleanup() { + # Restore cursor + show_cursor + # Kill any background processes + if [[ -n "${SUDO_KEEPALIVE_PID:-}" ]]; then + kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true + fi + if [[ -n "${SPINNER_PID:-}" ]]; then + kill "$SPINNER_PID" 2>/dev/null || true + fi + exit "${1:-0}" +} + +# Set trap for cleanup on exit +trap cleanup EXIT INT TERM + main() { case "${1:-""}" in "--help"|"-h") @@ -876,11 +775,14 @@ main() { echo " --help, -h Show this help" echo "" echo "Interactive cleanup with smart password handling" + echo "" exit 0 ;; *) + hide_cursor start_cleanup perform_cleanup + show_cursor ;; esac } diff --git a/bin/install.sh b/bin/install.sh deleted file mode 100755 index 2e8dcb9..0000000 --- a/bin/install.sh +++ /dev/null @@ -1,388 +0,0 @@ -#!/bin/bash -# Mole - Install Module -# Interactive application installer using Homebrew -# -# Usage: -# install.sh # Launch interactive installer -# install.sh --help # Show help information - -set -euo pipefail - -# Get script directory and source common functions -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/../lib/common.sh" - -# Check if Homebrew is available -check_homebrew() { - if ! command -v brew >/dev/null 2>&1; then - log_error "Homebrew is not installed" - echo "" - echo "To install Homebrew, run:" - echo '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' - exit 1 - fi -} - -# Application categories with descriptions -declare -A APP_CATEGORIES=( - ["productivity"]="๐Ÿ“ Productivity Apps" - ["development"]="๐Ÿ’ป Development Tools" - ["media"]="๐ŸŽต Media & Entertainment" - ["utilities"]="๐Ÿ”ง System Utilities" - ["communication"]="๐Ÿ’ฌ Communication" - ["design"]="๐ŸŽจ Design & Graphics" -) - -# Define applications by category -declare -A APPS=( - # Productivity - ["notion"]="productivity|Notion|All-in-one workspace for notes and docs" - ["obsidian"]="productivity|Obsidian|Knowledge management and note-taking" - ["raycast"]="productivity|Raycast|Launcher and productivity tool" - ["alfred"]="productivity|Alfred|Application launcher and productivity app" - ["1password"]="productivity|1Password|Password manager" - - # Development - ["visual-studio-code"]="development|VS Code|Code editor by Microsoft" - ["docker"]="development|Docker|Containerization platform" - ["postman"]="development|Postman|API development and testing" - ["github-desktop"]="development|GitHub Desktop|Git client for GitHub" - ["figma"]="development|Figma|Design and prototyping tool" - ["iterm2"]="development|iTerm2|Terminal replacement" - - # Media - ["vlc"]="media|VLC|Media player" - ["spotify"]="media|Spotify|Music streaming" - ["handbrake"]="media|HandBrake|Video transcoder" - ["obs"]="media|OBS Studio|Live streaming and recording" - - # Utilities - ["the-unarchiver"]="utilities|The Unarchiver|Archive utility" - ["appcleaner"]="utilities|AppCleaner|Uninstall applications completely" - ["cleanmymac"]="utilities|CleanMyMac X|System cleaning and optimization" - ["bartender-4"]="utilities|Bartender 4|Menu bar organization" - - # Communication - ["discord"]="communication|Discord|Voice and text chat" - ["slack"]="communication|Slack|Team communication" - ["telegram"]="communication|Telegram|Messaging app" - ["zoom"]="communication|Zoom|Video conferencing" - - # Design - ["sketch"]="design|Sketch|Digital design toolkit" - ["adobe-creative-cloud"]="design|Adobe CC|Creative suite" - ["blender"]="design|Blender|3D creation suite" -) - -# Initialize global variables -declare -a selected_apps=() -declare -a filtered_apps=() -current_category="all" -current_line=0 - -# Help information -show_help() { - echo "Mole - Interactive App Installer" - echo "=================================" - echo "" - echo "Description: Install useful applications using Homebrew Cask" - echo "" - echo "Features:" - echo " โ€ข Browse apps by category" - echo " โ€ข Navigate with โ†‘/โ†“ arrow keys" - echo " โ€ข Select/deselect apps with SPACE" - echo " โ€ข Filter by category with 1-6 keys" - echo " โ€ข Install selected apps with ENTER" - echo " โ€ข Quit anytime with 'q'" - echo "" - echo "Usage:" - echo " ./install.sh Launch interactive installer" - echo " ./install.sh --help Show this help message" - echo "" - echo "Requirements:" - echo " โ€ข Homebrew must be installed" - echo " โ€ข Internet connection for downloads" - echo "" -} - -# Parse arguments -if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then - show_help - exit 0 -fi - -# Filter apps by category -filter_apps_by_category() { - local category="$1" - filtered_apps=() - - for app_key in "${!APPS[@]}"; do - IFS='|' read -r app_category app_name app_desc <<< "${APPS[$app_key]}" - if [[ "$category" == "all" || "$app_category" == "$category" ]]; then - filtered_apps+=("$app_key|$app_category|$app_name|$app_desc") - fi - done - - # Sort alphabetically by name - IFS=$'\n' filtered_apps=($(sort -t'|' -k3 <<<"${filtered_apps[*]}")) - unset IFS -} - -# Display application list -display_apps() { - clear - echo "๐Ÿ“ฆ Mole - Interactive App Installer" - echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" - echo "" - - # Show category filter - local category_name="All Applications" - case "$current_category" in - "productivity") category_name="${APP_CATEGORIES[productivity]}" ;; - "development") category_name="${APP_CATEGORIES[development]}" ;; - "media") category_name="${APP_CATEGORIES[media]}" ;; - "utilities") category_name="${APP_CATEGORIES[utilities]}" ;; - "communication") category_name="${APP_CATEGORIES[communication]}" ;; - "design") category_name="${APP_CATEGORIES[design]}" ;; - esac - - echo -e "${PURPLE}Category: $category_name${NC}" - echo -e "${PURPLE}Showing ${#filtered_apps[@]} applications${NC}" - echo "" - - # Display apps (max 15 per page) - local start_idx=0 - local end_idx=$((${#filtered_apps[@]} - 1)) - local max_display=15 - - if [[ $end_idx -gt $((max_display - 1)) ]]; then - end_idx=$((max_display - 1)) - fi - - for ((i=start_idx; i<=end_idx && i<${#filtered_apps[@]}; i++)); do - IFS='|' read -r app_key app_category app_name app_desc <<< "${filtered_apps[i]}" - - local prefix=" " - local line_color="$NC" - local name_color="$NC" - - # Current selection highlighting - if [[ $i -eq $current_line ]]; then - prefix="โ–ถ " - line_color="$BLUE" - name_color="$BLUE" - fi - - # Check if app is selected - local checkbox="[ ]" - local checkbox_color="$NC" - for selected in "${selected_apps[@]}"; do - if [[ "$selected" == "$app_key" ]]; then - checkbox="[โœ“]" - checkbox_color="$GREEN" - break - fi - done - - # Format display - printf "${line_color}${prefix}${checkbox_color}${checkbox}${NC} " - printf "${name_color}%-25s${NC} " "$app_name" - printf "โ”‚ %s\n" "$app_desc" - done - - echo "" - echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" - - # Show selection summary - local selected_count=${#selected_apps[@]} - if [[ $selected_count -eq 0 ]]; then - echo -e "${BLUE}๐Ÿ“‹ No applications selected${NC}" - else - echo -e "${GREEN}๐Ÿ“‹ Selected: $selected_count applications${NC}" - fi - - echo "" - - # Show category filters - echo -e "${PURPLE}๐Ÿท๏ธ Categories:${NC}" - echo " 0 All 1 Productivity 2 Development 3 Media 4 Utilities 5 Communication 6 Design" - echo "" - - # Controls - echo -e "${PURPLE}๐ŸŽฎ Controls:${NC}" - echo " โ†‘/โ†“ Navigate SPACE Select 0-6 Filter ENTER Install ? Help q Quit" -} - -# Interactive app selection -interactive_app_selection() { - filter_apps_by_category "$current_category" - current_line=0 - - while true; do - display_apps - - # Read key input - read -rsn1 key - - case "$key" in - $'\x1b') # ESC sequences - read -rsn2 key - case "$key" in - '[A') # Up arrow - ((current_line > 0)) && ((current_line--)) - ;; - '[B') # Down arrow - ((current_line < ${#filtered_apps[@]} - 1)) && ((current_line++)) - ;; - esac - ;; - ' ') # Space - toggle selection - if [[ ${#filtered_apps[@]} -gt 0 && $current_line -lt ${#filtered_apps[@]} ]]; then - IFS='|' read -r app_key app_category app_name app_desc <<< "${filtered_apps[current_line]}" - - # Check if already selected - local found=false - for i in "${!selected_apps[@]}"; do - if [[ "${selected_apps[i]}" == "$app_key" ]]; then - unset 'selected_apps[i]' - selected_apps=("${selected_apps[@]}") # Re-index array - found=true - break - fi - done - - if [[ "$found" == "false" ]]; then - selected_apps+=("$app_key") - fi - fi - ;; - $'\n'|$'\r') # Enter - proceed to installation - if [[ ${#selected_apps[@]} -gt 0 ]]; then - break - fi - ;; - 'q'|'Q') # Quit - log_info "Installation cancelled" - return 1 - ;; - [0-6]) # Category filters - case "$key" in - '0') current_category="all" ;; - '1') current_category="productivity" ;; - '2') current_category="development" ;; - '3') current_category="media" ;; - '4') current_category="utilities" ;; - '5') current_category="communication" ;; - '6') current_category="design" ;; - esac - filter_apps_by_category "$current_category" - current_line=0 - ;; - 'a'|'A') # Select all visible - for app_data in "${filtered_apps[@]}"; do - IFS='|' read -r app_key app_category app_name app_desc <<< "$app_data" - - # Check if already selected - local found=false - for selected in "${selected_apps[@]}"; do - if [[ "$selected" == "$app_key" ]]; then - found=true - break - fi - done - - if [[ "$found" == "false" ]]; then - selected_apps+=("$app_key") - fi - done - ;; - 'n'|'N') # Select none - selected_apps=() - ;; - '?') # Help - show_help - echo "" - read -p "Press any key to continue..." -n 1 -r - ;; - esac - done - - return 0 -} - -# Install selected applications -install_applications() { - log_header "Installing selected applications" - - echo "You selected ${#selected_apps[@]} application(s) for installation:" - echo "" - - for app_key in "${selected_apps[@]}"; do - IFS='|' read -r app_category app_name app_desc <<< "${APPS[$app_key]}" - echo " โ€ข $app_name - $app_desc" - done - - echo "" - read -p "Continue with installation? (y/N): " -n 1 -r - echo - - if [[ $REPLY =~ ^[Yy]$ ]]; then - echo "" - log_info "Starting installation..." - echo "" - - local successful=0 - local failed=0 - - for app_key in "${selected_apps[@]}"; do - IFS='|' read -r app_category app_name app_desc <<< "${APPS[$app_key]}" - - echo -e "${BLUE}Installing $app_name...${NC}" - - if brew install --cask "$app_key" 2>/dev/null; then - echo -e " ${GREEN}โœ“${NC} $app_name installed successfully" - ((successful++)) - else - echo -e " ${RED}โœ—${NC} Failed to install $app_name" - ((failed++)) - fi - echo "" - done - - # Summary - echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" - log_success "Installation complete!" - echo "๐Ÿ“Š Successfully installed: $successful applications" - if [[ $failed -gt 0 ]]; then - echo "โš ๏ธ Failed to install: $failed applications" - fi - else - log_info "Installation cancelled" - fi -} - -# Main function -main() { - echo "๐Ÿ“ฆ Mole - Interactive App Installer" - echo "====================================" - echo "" - - # Check Homebrew - check_homebrew - - log_info "Checking Homebrew installation..." - echo "" - - # Interactive selection - if ! interactive_app_selection; then - return 0 - fi - - clear - install_applications - - log_success "App installer finished" -} - -# Run main function -main "$@" \ No newline at end of file diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 9f629d7..4606ea2 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -11,41 +11,24 @@ set -euo pipefail # Get script directory and source common functions SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/common.sh" -source "$SCRIPT_DIR/../lib/menu.sh" +source "$SCRIPT_DIR/../lib/paginated_menu.sh" source "$SCRIPT_DIR/../lib/app_selector.sh" source "$SCRIPT_DIR/../lib/batch_uninstall.sh" -# Basic preserved bundle patterns -PRESERVED_BUNDLE_PATTERNS=( - "com.apple.*" - "com.nektony.*" -) - -# Check if bundle should be preserved (system 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 -} +# Note: Bundle preservation logic is now in lib/common.sh # Help information show_help() { - echo "Mole - Interactive App Uninstaller" - echo "========================================" + echo "App Uninstaller" + echo "===============" echo "" - echo "Description: Interactive tool to uninstall applications and clean their data" + echo "Uninstall applications and clean their data completely." echo "" - echo "Features:" - echo " โ€ข Navigate with โ†‘/โ†“ arrow keys" - echo " โ€ข Select/deselect apps with SPACE" - echo " โ€ข Confirm selection with ENTER" - echo " โ€ข Quit anytime with 'q'" - echo " โ€ข Apps sorted by last usage time" - echo " โ€ข Comprehensive cleanup of app data" + echo "Controls:" + echo " โ†‘/โ†“ Navigate" + echo " SPACE Select/deselect" + echo " ENTER Confirm" + echo " Q Quit" echo "" echo "Usage:" echo " ./uninstall.sh Launch interactive uninstaller" @@ -69,7 +52,7 @@ if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then fi # Initialize global variables -declare -a selected_apps=() +selected_apps=() # Global array for app selection declare -a apps_data=() declare -a selection_state=() current_line=0 @@ -109,7 +92,7 @@ get_app_last_used() { scan_applications() { local temp_file=$(mktemp) - echo -n "Scanning applications... " >&2 + echo -n "Scanning... " >&2 # Pre-cache current epoch to avoid repeated calls local current_epoch=$(date "+%s") @@ -121,10 +104,80 @@ scan_applications() { local app_name=$(basename "$app_path" .app) - # Quick bundle ID check first (only if plist exists) + # Try to get English name from bundle info, fallback to folder name local bundle_id="unknown" + local display_name="$app_name" if [[ -f "$app_path/Contents/Info.plist" ]]; then bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2>/dev/null || echo "unknown") + + # Try to get English name from bundle info + local bundle_executable=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2>/dev/null) + + # Smart display name selection - prefer descriptive names over generic ones + local candidates=() + + # Get all potential names + local bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2>/dev/null) + local bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2>/dev/null) + + # Check if executable name is generic/technical (should be avoided) + local is_generic_executable=false + if [[ -n "$bundle_executable" ]]; then + case "$bundle_executable" in + "pake"|"Electron"|"electron"|"nwjs"|"node"|"helper"|"main"|"app"|"binary") + is_generic_executable=true + ;; + esac + fi + + # Priority order for name selection: + # 1. App folder name (if ASCII and descriptive) - often the most complete name + if [[ "$app_name" =~ ^[A-Za-z0-9\ ._-]+$ && ${#app_name} -gt 3 ]]; then + candidates+=("$app_name") + fi + + # 2. CFBundleDisplayName (if meaningful and ASCII) + if [[ -n "$bundle_display_name" && "$bundle_display_name" =~ ^[A-Za-z0-9\ ._-]+$ ]]; then + candidates+=("$bundle_display_name") + fi + + # 3. CFBundleName (if meaningful and ASCII) + if [[ -n "$bundle_name" && "$bundle_name" =~ ^[A-Za-z0-9\ ._-]+$ && "$bundle_name" != "$bundle_display_name" ]]; then + candidates+=("$bundle_name") + fi + + # 4. CFBundleExecutable (only if not generic and ASCII) + if [[ -n "$bundle_executable" && "$bundle_executable" =~ ^[A-Za-z0-9._-]+$ && "$is_generic_executable" == false ]]; then + candidates+=("$bundle_executable") + fi + + # 5. Fallback to non-ASCII names if no ASCII found + if [[ ${#candidates[@]} -eq 0 ]]; then + [[ -n "$bundle_display_name" ]] && candidates+=("$bundle_display_name") + [[ -n "$bundle_name" && "$bundle_name" != "$bundle_display_name" ]] && candidates+=("$bundle_name") + candidates+=("$app_name") + fi + + # Select the first (best) candidate + display_name="${candidates[0]:-$app_name}" + + # Brand name mapping for better user recognition (post-process) + case "$display_name" in + "qiyimac"|"็ˆฑๅฅ‡่‰บ") display_name="iQiyi" ;; + "wechat"|"ๅพฎไฟก") display_name="WeChat" ;; + "QQ"|"QQ") display_name="QQ" ;; + "VooV Meeting"|"่…พ่ฎฏไผš่ฎฎ") display_name="VooV Meeting" ;; + "dingtalk"|"้’‰้’‰") display_name="DingTalk" ;; + "NeteaseMusic"|"็ฝ‘ๆ˜“ไบ‘้Ÿณไน") display_name="NetEase Music" ;; + "BaiduNetdisk"|"็™พๅบฆ็ฝ‘็›˜") display_name="Baidu NetDisk" ;; + "alipay"|"ๆ”ฏไป˜ๅฎ") display_name="Alipay" ;; + "taobao"|"ๆท˜ๅฎ") display_name="Taobao" ;; + "futunn"|"ๅฏŒ้€”็‰›็‰›") display_name="Futu NiuNiu" ;; + "tencent lemon"|"Tencent Lemon Cleaner") display_name="Tencent Lemon" ;; + "keynote"|"Keynote") display_name="Keynote" ;; + "pages"|"Pages") display_name="Pages" ;; + "numbers"|"Numbers") display_name="Numbers" ;; + esac fi # Skip protected system apps early @@ -132,8 +185,8 @@ scan_applications() { continue fi - # Store tuple: app_path|app_name|bundle_id - app_data_tuples+=("${app_path}|${app_name}|${bundle_id}") + # Store tuple: app_path|app_name|bundle_id|display_name + app_data_tuples+=("${app_path}|${app_name}|${bundle_id}|${display_name}") done < <(find /Applications -name "*.app" -maxdepth 1 -print0 2>/dev/null) # Second pass: process each app with accurate size calculation @@ -141,12 +194,12 @@ scan_applications() { local total_apps=${#app_data_tuples[@]} for app_data_tuple in "${app_data_tuples[@]}"; do - IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple" + IFS='|' read -r app_path app_name bundle_id display_name <<< "$app_data_tuple" # Show progress every few items ((app_count++)) - if (( app_count % 3 == 0 )) || [[ $app_count -eq $total_apps ]]; then - echo -ne "\rScanning applications... processing $app_count/$total_apps apps" >&2 + if (( app_count % 5 == 0 )) || [[ $app_count -eq $total_apps ]]; then + echo -ne "\rScanning... $app_count/$total_apps" >&2 fi # Accurate size calculation - this is what takes time but user wants it @@ -155,27 +208,70 @@ scan_applications() { app_size=$(du -sh "$app_path" 2>/dev/null | cut -f1 || echo "N/A") fi - # Simplified last used check using file modification time - local last_used="Old" + # Get real last used date from macOS metadata + local last_used="Never" local last_used_epoch=0 if [[ -d "$app_path" ]]; then - last_used_epoch=$(stat -f%m "$app_path" 2>/dev/null || echo "0") - if [[ $last_used_epoch -gt 0 ]]; then - local days_ago=$(( (current_epoch - last_used_epoch) / 86400 )) - if [[ $days_ago -lt 30 ]]; then - last_used="Recent" - elif [[ $days_ago -lt 365 ]]; then - last_used="This year" + local metadata_date=$(mdls -name kMDItemLastUsedDate -raw "$app_path" 2>/dev/null) + + if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then + # Convert macOS date format to epoch + last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2>/dev/null || echo "0") + + if [[ $last_used_epoch -gt 0 ]]; then + local days_ago=$(( (current_epoch - last_used_epoch) / 86400 )) + + if [[ $days_ago -eq 0 ]]; then + last_used="Today" + elif [[ $days_ago -eq 1 ]]; then + last_used="Yesterday" + elif [[ $days_ago -lt 7 ]]; then + last_used="${days_ago} days ago" + elif [[ $days_ago -lt 30 ]]; then + local weeks_ago=$(( days_ago / 7 )) + if [[ $weeks_ago -eq 1 ]]; then + last_used="1 week ago" + else + last_used="${weeks_ago} weeks ago" + fi + elif [[ $days_ago -lt 365 ]]; then + local months_ago=$(( days_ago / 30 )) + if [[ $months_ago -eq 1 ]]; then + last_used="1 month ago" + else + last_used="${months_ago} months ago" + fi + else + local years_ago=$(( days_ago / 365 )) + if [[ $years_ago -eq 1 ]]; then + last_used="1 year ago" + else + last_used="${years_ago} years ago" + fi + fi + fi + else + # Fallback to file modification time if no usage metadata + last_used_epoch=$(stat -f%m "$app_path" 2>/dev/null || echo "0") + if [[ $last_used_epoch -gt 0 ]]; then + local days_ago=$(( (current_epoch - last_used_epoch) / 86400 )) + if [[ $days_ago -lt 30 ]]; then + last_used="Recent" + elif [[ $days_ago -lt 365 ]]; then + last_used="This year" + else + last_used="Old" + fi fi fi fi - # Format: epoch|app_path|app_name|bundle_id|size|last_used_display - echo "${last_used_epoch}|${app_path}|${app_name}|${bundle_id}|${app_size}|${last_used}" >> "$temp_file" + # Format: epoch|app_path|display_name|bundle_id|size|last_used_display + echo "${last_used_epoch}|${app_path}|${display_name}|${bundle_id}|${app_size}|${last_used}" >> "$temp_file" done - echo -e "\rScanning applications... found $app_count apps โœ“" >&2 + echo -e "\rFound $app_count applications โœ“" >&2 # Check if we found any applications if [[ ! -s "$temp_file" ]]; then @@ -221,57 +317,8 @@ load_applications() { # Read a single key with proper escape sequence handling # This function has been replaced by the menu.sh library -# Old interactive_app_selection and show_selection_help functions removed -# They have been replaced by the new menu system in lib/app_selector.sh - -# Find and list app-related files -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) - - printf '%s\n' "${files_to_clean[@]}" -} - -# Calculate total size of files -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" -} +# Note: App file discovery and size calculation functions moved to lib/common.sh +# Use find_app_files() and calculate_total_size() from common.sh # Uninstall selected applications uninstall_applications() { @@ -384,8 +431,8 @@ uninstall_applications() { # Cleanup function - restore cursor and clean up cleanup() { - # Restore cursor - printf '\033[?25h' + # Restore cursor using common function + show_cursor exit "${1:-0}" } @@ -394,10 +441,9 @@ trap cleanup EXIT INT TERM # Main function main() { - echo "๐Ÿ—‘๏ธ Mole - Interactive App Uninstaller" - echo "============================================" - echo - + # Hide cursor during operation + hide_cursor + # Scan applications local apps_file=$(scan_applications) @@ -412,17 +458,16 @@ main() { return 1 fi - # Interactive selection using new menu system + # Interactive selection using paginated menu if ! select_apps_for_uninstall; then rm -f "$apps_file" return 0 fi - # Restore cursor for normal interaction - printf '\033[?25h' + # Restore cursor for normal interaction after selection + show_cursor clear echo "You selected ${#selected_apps[@]} application(s) for uninstallation:" - echo "" if [[ ${#selected_apps[@]} -gt 0 ]]; then for selected_app in "${selected_apps[@]}"; do @@ -439,8 +484,6 @@ main() { # Cleanup rm -f "$apps_file" - - log_success "App uninstaller finished" } # Run main function diff --git a/install.sh b/install.sh index 60f1593..bd43718 100755 --- a/install.sh +++ b/install.sh @@ -25,6 +25,9 @@ INSTALL_DIR="/usr/local/bin" CONFIG_DIR="$HOME/.config/mole" SOURCE_DIR="" +# Default action (install|update) +ACTION="install" + show_help() { cat << 'EOF' Mole Installation Script @@ -36,12 +39,14 @@ USAGE: OPTIONS: --prefix PATH Install to custom directory (default: /usr/local/bin) --config PATH Config directory (default: ~/.config/mole) + --update Update Mole to the latest version --uninstall Uninstall mole --help, -h Show this help EXAMPLES: ./install.sh # Install to /usr/local/bin ./install.sh --prefix ~/.local/bin # Install to custom directory + ./install.sh --update # Update Mole in place ./install.sh --uninstall # Uninstall mole The installer will: @@ -49,22 +54,27 @@ The installer will: 2. Set up config directory with all modules 3. Make the mole command available system-wide EOF + echo "" } # Resolve the directory containing source files (supports curl | bash) resolve_source_dir() { - # 1) If script is on disk, use its directory + if [[ -n "$SOURCE_DIR" && -d "$SOURCE_DIR" && -f "$SOURCE_DIR/mole" ]]; then + return 0 + fi + + # 1) If script is on disk, use its directory (only when mole executable present) if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then local script_dir script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - if [[ -f "$script_dir/mole" || -d "$script_dir/bin" || -d "$script_dir/lib" ]]; then + if [[ -f "$script_dir/mole" ]]; then SOURCE_DIR="$script_dir" return 0 fi fi # 2) If CLEAN_SOURCE_DIR env is provided, honor it - if [[ -n "${CLEAN_SOURCE_DIR:-}" && -d "$CLEAN_SOURCE_DIR" ]]; then + if [[ -n "${CLEAN_SOURCE_DIR:-}" && -d "$CLEAN_SOURCE_DIR" && -f "$CLEAN_SOURCE_DIR/mole" ]]; then SOURCE_DIR="$CLEAN_SOURCE_DIR" return 0 fi @@ -100,6 +110,20 @@ resolve_source_dir() { exit 1 } +get_source_version() { + local source_mole="$SOURCE_DIR/mole" + if [[ -f "$source_mole" ]]; then + sed -n 's/^VERSION="\(.*\)"$/\1/p' "$source_mole" | head -n1 + fi +} + +get_installed_version() { + local binary="$INSTALL_DIR/mole" + if [[ -x "$binary" ]]; then + "$binary" --version 2>/dev/null | awk 'NF {print $NF; exit}' + fi +} + # Parse command line arguments parse_args() { while [[ $# -gt 0 ]]; do @@ -112,6 +136,10 @@ parse_args() { CONFIG_DIR="$2" shift 2 ;; + --update) + ACTION="update" + shift 1 + ;; --uninstall) uninstall_mole exit 0 @@ -171,14 +199,25 @@ install_files() { resolve_source_dir - # Copy main executable + local source_dir_abs + local install_dir_abs + local config_dir_abs + source_dir_abs="$(cd "$SOURCE_DIR" && pwd)" + install_dir_abs="$(cd "$INSTALL_DIR" && pwd)" + config_dir_abs="$(cd "$CONFIG_DIR" && pwd)" + + # Copy main executable when destination differs if [[ -f "$SOURCE_DIR/mole" ]]; then - if [[ "$INSTALL_DIR" == "/usr/local/bin" ]] && [[ ! -w "$INSTALL_DIR" ]]; then - sudo cp "$SOURCE_DIR/mole" "$INSTALL_DIR/mole" - sudo chmod +x "$INSTALL_DIR/mole" + if [[ "$source_dir_abs" == "$install_dir_abs" ]]; then + log_info "Mole binary already present in $INSTALL_DIR" else - cp "$SOURCE_DIR/mole" "$INSTALL_DIR/mole" - chmod +x "$INSTALL_DIR/mole" + if [[ "$INSTALL_DIR" == "/usr/local/bin" ]] && [[ ! -w "$INSTALL_DIR" ]]; then + sudo cp "$SOURCE_DIR/mole" "$INSTALL_DIR/mole" + sudo chmod +x "$INSTALL_DIR/mole" + else + cp "$SOURCE_DIR/mole" "$INSTALL_DIR/mole" + chmod +x "$INSTALL_DIR/mole" + fi fi else log_error "mole executable not found in ${SOURCE_DIR:-unknown}" @@ -187,26 +226,46 @@ install_files() { # Copy configuration and modules if [[ -d "$SOURCE_DIR/bin" ]]; then - cp -r "$SOURCE_DIR/bin"/* "$CONFIG_DIR/bin/" - chmod +x "$CONFIG_DIR/bin"/* + local source_bin_abs="$(cd "$SOURCE_DIR/bin" && pwd)" + local config_bin_abs="$(cd "$CONFIG_DIR/bin" && pwd)" + if [[ "$source_bin_abs" == "$config_bin_abs" ]]; then + log_info "Configuration bin directory already synced" + else + cp -r "$SOURCE_DIR/bin"/* "$CONFIG_DIR/bin/" + chmod +x "$CONFIG_DIR/bin"/* + fi fi if [[ -d "$SOURCE_DIR/lib" ]]; then - cp -r "$SOURCE_DIR/lib"/* "$CONFIG_DIR/lib/" + local source_lib_abs="$(cd "$SOURCE_DIR/lib" && pwd)" + local config_lib_abs="$(cd "$CONFIG_DIR/lib" && pwd)" + if [[ "$source_lib_abs" == "$config_lib_abs" ]]; then + log_info "Configuration lib directory already synced" + else + cp -r "$SOURCE_DIR/lib"/* "$CONFIG_DIR/lib/" + fi fi - # Copy other files if they exist - for file in README.md LICENSE; do - if [[ -f "$SOURCE_DIR/$file" ]]; then - cp "$SOURCE_DIR/$file" "$CONFIG_DIR/" - fi - done + # Copy other files if they exist and directories differ + if [[ "$config_dir_abs" != "$source_dir_abs" ]]; then + for file in README.md LICENSE install.sh; do + if [[ -f "$SOURCE_DIR/$file" ]]; then + cp -f "$SOURCE_DIR/$file" "$CONFIG_DIR/" + fi + done + fi - # Update the mole script to use the config directory - if [[ "$INSTALL_DIR" == "/usr/local/bin" ]] && [[ ! -w "$INSTALL_DIR" ]]; then - sudo sed -i '' "s|SCRIPT_DIR=.*|SCRIPT_DIR=\"$CONFIG_DIR\"|" "$INSTALL_DIR/mole" - else - sed -i '' "s|SCRIPT_DIR=.*|SCRIPT_DIR=\"$CONFIG_DIR\"|" "$INSTALL_DIR/mole" + if [[ -f "$CONFIG_DIR/install.sh" ]]; then + chmod +x "$CONFIG_DIR/install.sh" + fi + + # Update the mole script to use the config directory when installed elsewhere + if [[ "$source_dir_abs" != "$install_dir_abs" ]]; then + if [[ "$INSTALL_DIR" == "/usr/local/bin" ]] && [[ ! -w "$INSTALL_DIR" ]]; then + sudo sed -i '' "s|SCRIPT_DIR=.*|SCRIPT_DIR=\"$CONFIG_DIR\"|" "$INSTALL_DIR/mole" + else + sed -i '' "s|SCRIPT_DIR=.*|SCRIPT_DIR=\"$CONFIG_DIR\"|" "$INSTALL_DIR/mole" + fi fi } @@ -217,7 +276,7 @@ verify_installation() { # Test if mole command works if "$INSTALL_DIR/mole" --help >/dev/null 2>&1; then - log_success "" + return 0 else log_warning "Mole command installed but may not be working properly" fi @@ -245,6 +304,43 @@ setup_path() { fi } +print_usage_summary() { + local action="$1" + local new_version="$2" + local previous_version="${3:-}" + + if [[ ${VERBOSE} -ne 1 ]]; then + return + fi + + local message="Mole ${action} successfully" + + if [[ "$action" == "updated" && -n "$previous_version" && -n "$new_version" && "$previous_version" != "$new_version" ]]; then + message+=" (${previous_version} -> ${new_version})" + elif [[ -n "$new_version" ]]; then + message+=" (version ${new_version})" + fi + + log_success "$message!" + + echo "" + echo "Usage:" + if [[ ":$PATH:" == *":$INSTALL_DIR:"* ]]; then + echo " mole # Interactive menu" + echo " mole clean # System cleanup" + echo " mole uninstall # Remove applications" + echo " mole update # Update Mole to the latest version" + echo " mole --version # Show installed version" + else + echo " $INSTALL_DIR/mole # Interactive menu" + echo " $INSTALL_DIR/mole clean # System cleanup" + echo " $INSTALL_DIR/mole uninstall # Remove applications" + echo " $INSTALL_DIR/mole update # Update Mole to the latest version" + echo " $INSTALL_DIR/mole --version # Show installed version" + fi + echo "" +} + # Uninstall function uninstall_mole() { log_info "Uninstalling mole..." @@ -276,7 +372,10 @@ uninstall_mole() { } # Main installation function -main() { +perform_install() { + resolve_source_dir + local source_version + source_version="$(get_source_version || true)" check_requirements create_directories @@ -284,23 +383,67 @@ main() { verify_installation setup_path - if [[ ${VERBOSE} -eq 1 ]]; then - log_success "Mole installed successfully!" - echo "" - echo "Usage:" - if [[ ":$PATH:" == *":$INSTALL_DIR:"* ]]; then - echo " mole # Interactive menu" - echo " mole clean # System cleanup" - echo " mole uninstall # Remove applications" - else - echo " $INSTALL_DIR/mole # Interactive menu" - echo " $INSTALL_DIR/mole clean # System cleanup" - echo " $INSTALL_DIR/mole uninstall # Remove applications" - fi - echo "" + local installed_version + installed_version="$(get_installed_version || true)" + + if [[ -z "$installed_version" ]]; then + installed_version="$source_version" fi + + print_usage_summary "installed" "$installed_version" } -# Run installation +perform_update() { + check_requirements + + local installed_version + installed_version="$(get_installed_version || true)" + + if [[ -z "$installed_version" ]]; then + log_warning "Mole is not currently installed in $INSTALL_DIR. Running fresh installation." + perform_install + return + fi + + resolve_source_dir + local target_version + target_version="$(get_source_version || true)" + + if [[ -z "$target_version" ]]; then + log_error "Unable to determine the latest Mole version." + exit 1 + fi + + if [[ "$installed_version" == "$target_version" ]]; then + log_success "Mole is already up to date (version $installed_version)!" + exit 0 + fi + + log_info "Updating Mole from $installed_version to $target_version..." + + create_directories + install_files + verify_installation + setup_path + + local updated_version + updated_version="$(get_installed_version || true)" + + if [[ -z "$updated_version" ]]; then + updated_version="$target_version" + fi + + print_usage_summary "updated" "$updated_version" "$installed_version" +} + +# Run requested action parse_args "$@" -main + +case "$ACTION" in + update) + perform_update + ;; + *) + perform_install + ;; +esac diff --git a/lib/app_selector.sh b/lib/app_selector.sh index 4eeac4c..ebc2664 100755 --- a/lib/app_selector.sh +++ b/lib/app_selector.sh @@ -1,39 +1,40 @@ #!/bin/bash +# App selection functionality -# App selection functionality using the new menu system -# This replaces the complex interactive_app_selection function +set -euo pipefail -# Interactive app selection using the menu.sh library +# Format app info for display +format_app_display() { + local display_name="$1" size="$2" last_used="$3" + + # Truncate long names + local truncated_name="$display_name" + if [[ ${#display_name} -gt 24 ]]; then + truncated_name="${display_name:0:21}..." + fi + + # Format size + local size_str="Unknown" + [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]] && size_str="$size" + + printf "%-24s (%s) | %s" "$truncated_name" "$size_str" "$last_used" +} + +# Global variable to store selection result (bash 3.2 compatible) +MOLE_SELECTION_RESULT="" + +# Main app selection function select_apps_for_uninstall() { if [[ ${#apps_data[@]} -eq 0 ]]; then log_warning "No applications available for uninstallation" return 1 fi - # Build menu options from apps_data + # Build menu options local -a menu_options=() for app_data in "${apps_data[@]}"; do - IFS='|' read -r epoch app_path app_name bundle_id size last_used <<< "$app_data" - - # The size is already formatted (e.g., "91M", "2.1G"), so use it directly - local size_str="Unknown" - if [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]]; then - size_str="$size" - fi - - # Format display name with better width control - local display_name - local max_name_length=25 - local truncated_name="$app_name" - - # Truncate app name if too long - if [[ ${#app_name} -gt $max_name_length ]]; then - truncated_name="${app_name:0:$((max_name_length-3))}..." - fi - - # Create aligned display format - display_name=$(printf "%-${max_name_length}s %8s | %s" "$truncated_name" "($size_str)" "$last_used") - menu_options+=("$display_name") + IFS='|' read -r epoch app_path display_name bundle_id size last_used <<< "$app_data" + menu_options+=("$(format_app_display "$display_name" "$size" "$last_used")") done echo "" @@ -42,12 +43,9 @@ select_apps_for_uninstall() { echo "Found ${#apps_data[@]} apps. Select apps to remove:" echo "" - # Load paginated menu system (arrow key navigation) - source "$(dirname "${BASH_SOURCE[0]}")/paginated_menu.sh" - - # Use paginated multi-select menu with arrow key navigation - local selected_indices - selected_indices=$(paginated_multi_select "Select Apps to Remove" "${menu_options[@]}") + # Use paginated menu - result will be stored in MOLE_SELECTION_RESULT + MOLE_SELECTION_RESULT="" + paginated_multi_select "Select Apps to Remove" "${menu_options[@]}" local exit_code=$? if [[ $exit_code -ne 0 ]]; then @@ -55,103 +53,30 @@ select_apps_for_uninstall() { return 1 fi - if [[ -z "$selected_indices" ]]; then + if [[ -z "$MOLE_SELECTION_RESULT" ]]; then echo "No apps selected" return 1 fi - # Build selected_apps array from indices + # Build selected apps array (global variable in bin/uninstall.sh) + # Clear existing selections - compatible with bash 3.2 selected_apps=() - for idx in $selected_indices; do - # Validate that idx is a number - if [[ "$idx" =~ ^[0-9]+$ ]]; then + + # Parse indices and build selected apps array + # Convert space-separated string to array for better handling + read -a indices_array <<< "$MOLE_SELECTION_RESULT" + + for idx in "${indices_array[@]}"; do + if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -ge 0 ]] && [[ $idx -lt ${#apps_data[@]} ]]; then selected_apps+=("${apps_data[idx]}") fi done - echo "Selected ${#selected_apps[@]} apps" return 0 } -# Alternative simplified single-select interface for quick selection -quick_select_app() { - if [[ ${#apps_data[@]} -eq 0 ]]; then - log_warning "No applications available for uninstallation" - return 1 - fi - - # Build menu options from apps_data (same as above) - local -a menu_options=() - for app_data in "${apps_data[@]}"; do - IFS='|' read -r epoch app_path app_name bundle_id size last_used <<< "$app_data" - - # The size is already formatted (e.g., "91M", "2.1G"), so use it directly - local size_str="Unknown" - if [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]]; then - size_str="$size" - fi - - # Format display name with better width control - local display_name - local max_name_length=25 - local truncated_name="$app_name" - - # Truncate app name if too long - if [[ ${#app_name} -gt $max_name_length ]]; then - truncated_name="${app_name:0:$((max_name_length-3))}..." - fi - - # Create aligned display format - display_name=$(printf "%-${max_name_length}s %8s | %s" "$truncated_name" "($size_str)" "$last_used") - menu_options+=("$display_name") - done - - echo "" - echo "๐Ÿ—‘๏ธ Quick Uninstall" - echo "" - - # Use single-select menu - if show_menu "Quick Uninstall" "${menu_options[@]}"; then - local selected_idx=$? - selected_apps=("${apps_data[selected_idx]}") - echo "โœ… Selected: ${menu_options[selected_idx]}" - return 0 - else - echo "โŒ Operation cancelled" - return 1 - fi -} - -# Show app selection mode menu -show_app_selection_mode() { - echo "" - echo "๐Ÿ—‘๏ธ Application Uninstaller" - echo "" - - local mode_options=( - "Batch Mode (select multiple apps with checkboxes)" - "Quick Mode (select one app at a time)" - "Exit Uninstaller" - ) - - if show_menu "Choose uninstall mode:" "${mode_options[@]}"; then - local mode=$? - case $mode in - 0) - select_apps_for_uninstall - return $? - ;; - 1) - quick_select_app - return $? - ;; - 2) - echo "Goodbye!" - return 1 - ;; - esac - else - echo "Operation cancelled" - return 1 - fi -} \ No newline at end of file +# Export function for external use +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + echo "This is a library file. Source it from other scripts." >&2 + exit 1 +fi \ No newline at end of file diff --git a/lib/batch_uninstall.sh b/lib/batch_uninstall.sh index 49a4f5e..bbdfca6 100755 --- a/lib/batch_uninstall.sh +++ b/lib/batch_uninstall.sh @@ -2,55 +2,7 @@ # Batch uninstall functionality with minimal confirmations # Replaces the overly verbose individual confirmation approach - -# Find and list app-related files -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) - - printf '%s\n' "${files_to_clean[@]}" -} - -# Calculate total size of files -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" -} +# Note: find_app_files() and calculate_total_size() functions now in lib/common.sh # Batch uninstall with single confirmation batch_uninstall_applications() { @@ -83,14 +35,12 @@ batch_uninstall_applications() { ((total_estimated_size += total_kb)) # Store details for later use - app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$related_files") + # Base64 encode related_files to handle multi-line data safely + local encoded_files=$(echo "$related_files" | base64) + app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$encoded_files") done - # Show summary and get batch confirmation - echo "" - echo "๐Ÿ“Š Uninstallation Summary:" - echo " โ€ข Applications to remove: ${#selected_apps[@]}" - + # Format size display if [[ $total_estimated_size -gt 1048576 ]]; then local size_display=$(echo "$total_estimated_size" | awk '{printf "%.2fGB", $1/1024/1024}') elif [[ $total_estimated_size -gt 1024 ]]; then @@ -98,31 +48,26 @@ batch_uninstall_applications() { else local size_display="${total_estimated_size}KB" fi - echo " โ€ข Estimated space to free: $size_display" + # Show summary and get batch confirmation + echo "" + echo "Will remove ${#selected_apps[@]} applications, free $size_display" if [[ ${#running_apps[@]} -gt 0 ]]; then - echo " โ€ข โš ๏ธ Running apps that will be force-quit:" - for app in "${running_apps[@]}"; do - echo " - $app" - done + echo "Running apps will be force-quit: ${running_apps[*]}" fi - echo "" - echo "Selected applications:" - for selected_app in "${selected_apps[@]}"; do - IFS='|' read -r epoch app_path app_name bundle_id size last_used <<< "$selected_app" - echo " โ€ข $app_name ($size)" - done + read -p "Press ENTER to confirm, or any other key to cancel: " -r - echo "" - read -p "๐Ÿ—‘๏ธ Proceed with uninstalling ALL ${#selected_apps[@]} applications? This cannot be undone. (Y/n): " -n 1 -r - echo - - if [[ $REPLY =~ ^[Nn]$ ]]; then + if [[ -n "$REPLY" ]]; then log_info "Uninstallation cancelled by user" return 0 fi + echo "โšก Starting uninstallation in 3 seconds... (Press Ctrl+C to abort)" + sleep 1 && echo "โšก 2..." + sleep 1 && echo "โšก 1..." + sleep 1 + # Force quit running apps first (batch) if [[ ${#running_apps[@]} -gt 0 ]]; then echo "" @@ -142,9 +87,11 @@ batch_uninstall_applications() { local failed_count=0 for detail in "${app_details[@]}"; do - IFS='|' read -r app_name app_path bundle_id total_kb related_files <<< "$detail" + IFS='|' read -r app_name app_path bundle_id total_kb encoded_files <<< "$detail" + + # Decode the related files list + local related_files=$(echo "$encoded_files" | base64 -d) - echo "" echo "๐Ÿ—‘๏ธ Uninstalling: $app_name" # Remove the application @@ -178,20 +125,27 @@ batch_uninstall_applications() { # Show final summary echo "" - log_header "Uninstallation Complete" - + echo "====================================================================" + echo "๐ŸŽ‰ UNINSTALLATION COMPLETE!" + if [[ $success_count -gt 0 ]]; then if [[ $total_size_freed -gt 1048576 ]]; then - local freed_display=$(echo "$total_size_freed" | awk '{printf "%.2fGB", $1/1024/1024}') + local freed_display=$(echo "$total_size_freed" | awk '{printf "%.1fGB", $1/1024/1024}') elif [[ $total_size_freed -gt 1024 ]]; then local freed_display=$(echo "$total_size_freed" | awk '{printf "%.1fMB", $1/1024}') else local freed_display="${total_size_freed}KB" fi - log_success "Successfully uninstalled $success_count applications" - log_success "Freed $freed_display of disk space" + echo "๐Ÿ—‘๏ธ Apps uninstalled: $success_count | Space freed: $freed_display" + else + echo "๐Ÿ—‘๏ธ No applications were uninstalled" fi - + + if [[ $failed_count -gt 0 ]]; then + echo "โš ๏ธ Failed to uninstall: $failed_count" + fi + + echo "====================================================================" if [[ $failed_count -gt 0 ]]; then log_warning "$failed_count applications failed to uninstall" fi diff --git a/lib/common.sh b/lib/common.sh index 28c5415..0b061cc 100755 --- a/lib/common.sh +++ b/lib/common.sh @@ -78,10 +78,19 @@ 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 rest - IFS= read -rsn1 key || return 1 + # Use macOS bash 3.2 compatible read syntax + IFS= read -r -s -n 1 key || return 1 # Some terminals can yield empty on Enter with -n1; treat as ENTER if [[ -z "$key" ]]; then @@ -91,23 +100,23 @@ read_key() { case "$key" in $'\n'|$'\r') echo "ENTER" ;; - ' ') echo " " ;; + ' ') echo "SPACE" ;; 'q'|'Q') echo "QUIT" ;; 'a'|'A') echo "ALL" ;; 'n'|'N') echo "NONE" ;; '?') echo "HELP" ;; $'\x1b') # Read the next two bytes within 1s; works well on macOS bash 3.2 - if IFS= read -rsn2 -t 1 rest 2>/dev/null; then + if IFS= read -r -s -n 2 -t 1 rest 2>/dev/null; then case "$rest" in "[A") echo "UP" ;; "[B") echo "DOWN" ;; "[C") echo "RIGHT" ;; "[D") echo "LEFT" ;; - *) echo "ESC" ;; + *) echo "OTHER" ;; esac else - echo "ESC" + echo "OTHER" fi ;; *) echo "OTHER" ;; @@ -175,32 +184,6 @@ get_directory_size_bytes() { du -sk "$path" 2>/dev/null | cut -f1 | awk '{print $1 * 1024}' || echo "0" } -# Safe file operation with backup -safe_remove() { - local path="$1" - local backup_dir="${2:-/tmp/mole_backup_$(date +%s)}" - local backup_enabled="${MOLE_BACKUP_ENABLED:-true}" - - if [[ ! -e "$path" ]]; then - return 0 - fi - - if [[ "$backup_enabled" == "true" ]]; then - # Create backup directory if it doesn't exist - mkdir -p "$backup_dir" 2>/dev/null || return 1 - - local basename_path - basename_path=$(basename "$path") - - if ! cp -R "$path" "$backup_dir/$basename_path" 2>/dev/null; then - log_warning "Backup failed for $path, skipping removal" - return 1 - fi - log_info "Backup created at $backup_dir/$basename_path" - fi - - rm -rf "$path" 2>/dev/null || true -} # Permission checks check_sudo() { @@ -223,165 +206,179 @@ request_sudo() { fi } -# Configuration management -readonly CONFIG_FILE="${HOME}/.config/mole/config" - -# Load configuration with defaults +# Load basic configuration load_config() { - # Default configuration - MOLE_LOG_LEVEL="${MOLE_LOG_LEVEL:-INFO}" - MOLE_AUTO_CONFIRM="${MOLE_AUTO_CONFIRM:-false}" - MOLE_BACKUP_ENABLED="${MOLE_BACKUP_ENABLED:-true}" MOLE_MAX_LOG_SIZE="${MOLE_MAX_LOG_SIZE:-1048576}" - MOLE_PARALLEL_JOBS="${MOLE_PARALLEL_JOBS:-}" # Empty means auto-detect - - # Load user configuration if exists - if [[ -f "$CONFIG_FILE" ]]; then - source "$CONFIG_FILE" 2>/dev/null || true - fi } -# Save configuration -save_config() { - mkdir -p "$(dirname "$CONFIG_FILE")" 2>/dev/null || return 1 - cat > "$CONFIG_FILE" << EOF -# Mole Configuration File -# Generated on $(date) -# Log level: DEBUG, INFO, WARNING, ERROR -MOLE_LOG_LEVEL="$MOLE_LOG_LEVEL" -# Auto confirm operations (true/false) -MOLE_AUTO_CONFIRM="$MOLE_AUTO_CONFIRM" -# Enable backup before deletion (true/false) -MOLE_BACKUP_ENABLED="$MOLE_BACKUP_ENABLED" - -# Maximum log file size in bytes -MOLE_MAX_LOG_SIZE="$MOLE_MAX_LOG_SIZE" - -# Number of parallel jobs for operations (empty = auto-detect) -MOLE_PARALLEL_JOBS="$MOLE_PARALLEL_JOBS" -EOF -} - -# Progress tracking -# Use parameter expansion for portable global initialization (macOS bash lacks declare -g). -: "${PROGRESS_CURRENT:=0}" -: "${PROGRESS_TOTAL:=0}" -: "${PROGRESS_MESSAGE:=}" - -# Initialize progress tracking -init_progress() { - PROGRESS_CURRENT=0 - PROGRESS_TOTAL="$1" - PROGRESS_MESSAGE="${2:-Processing}" -} - -# Update progress -update_progress() { - PROGRESS_CURRENT="$1" - local message="${2:-$PROGRESS_MESSAGE}" - local percentage=$((PROGRESS_CURRENT * 100 / PROGRESS_TOTAL)) - - # Create progress bar - local bar_length=20 - local filled_length=$((percentage * bar_length / 100)) - local bar="" - - for ((i=0; i/dev/null || true - wait "$SPINNER_PID" 2>/dev/null || true - SPINNER_PID="" - printf "\r\033[K" # Clear the line - fi -} - -# Calculate optimal parallel jobs based on system resources -get_optimal_parallel_jobs() { - local operation_type="${1:-default}" - local optimal_parallel=4 - - # Try to detect optimal parallel jobs based on CPU cores - if command -v nproc >/dev/null 2>&1; then - optimal_parallel=$(nproc) - elif command -v sysctl >/dev/null 2>&1; then - optimal_parallel=$(sysctl -n hw.ncpu 2>/dev/null || echo 4) - fi - - # Apply operation-specific limits - case "$operation_type" in - "scan") - # For scanning: min 2, max 8 - if [[ $optimal_parallel -lt 2 ]]; then - optimal_parallel=2 - elif [[ $optimal_parallel -gt 8 ]]; then - optimal_parallel=8 - fi - ;; - "clean") - # For file operations: min 2, max 6 (more conservative) - if [[ $optimal_parallel -lt 2 ]]; then - optimal_parallel=2 - elif [[ $optimal_parallel -gt 6 ]]; then - optimal_parallel=6 - fi - ;; - *) - # Default: min 2, max 4 (safest) - if [[ $optimal_parallel -lt 2 ]]; then - optimal_parallel=2 - elif [[ $optimal_parallel -gt 4 ]]; then - optimal_parallel=4 - fi - ;; - esac - - # Use configured value if available, otherwise use calculated optimal - if [[ -n "${MOLE_PARALLEL_JOBS:-}" ]]; then - echo "$MOLE_PARALLEL_JOBS" - else - echo "$optimal_parallel" - fi -} # 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) + "com.tencent.inputmethod.*" + "com.sogou.*" + "com.baidu.*" + "*.inputmethod.*" + "*input*" + "*inputmethod*" + "*InputMethod*" + "*ime*" + "*IME*" + + # 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" +} diff --git a/lib/menu.sh b/lib/menu.sh deleted file mode 100755 index b3d3a9d..0000000 --- a/lib/menu.sh +++ /dev/null @@ -1,369 +0,0 @@ -#!/bin/bash - -# Simple interactive menu selector with arrow key support -# No external dependencies, compatible with most bash versions - -declare -a menu_options=() -declare -i selected=0 -declare -i menu_size=0 - -# ANSI escape sequences (allow reuse when sourced after lib/common.sh) -if [[ -z "${ESC+x}" ]]; then - readonly ESC=$'\033' -fi -readonly UP="${ESC}[A" -readonly DOWN="${ESC}[B" -readonly ENTER=$'\n' -readonly CLEAR_LINE="${ESC}[2K" -readonly HIDE_CURSOR="${ESC}[?25l" -readonly SHOW_CURSOR="${ESC}[?25h" - -# Set terminal to raw mode for reading single characters -setup_terminal() { - # Block until at least 1 byte to avoid false ENTER on empty reads - stty -echo -icanon min 1 time 0 -} - -# Restore terminal to normal mode -restore_terminal() { - stty echo icanon - printf "%s" "$SHOW_CURSOR" -} - -# Draw the menu -draw_menu() { - local force_full_redraw="${1:-true}" - printf "%s" "$HIDE_CURSOR" - - if [[ "$force_full_redraw" == "true" ]]; then - # Full redraw: clear and redraw all lines - for ((i = 0; i < menu_size; i++)); do - printf "\r%s" "$CLEAR_LINE" - - if [[ $i -eq $selected ]]; then - printf "โ–ถ \033[1;32m%s\033[0m\n" "${menu_options[i]}" - else - printf " %s\n" "${menu_options[i]}" - fi - done - - # Move cursor back to the beginning and save position - printf "${ESC}[%dA" $menu_size - printf "${ESC}7" # Save cursor position - else - # Quick update: only update changed lines - printf "${ESC}8" # Restore cursor position - - for ((i = 0; i < menu_size; i++)); do - printf "\r%s" "$CLEAR_LINE" - - if [[ $i -eq $selected ]]; then - printf "โ–ถ \033[1;32m%s\033[0m\n" "${menu_options[i]}" - else - printf " %s\n" "${menu_options[i]}" - fi - done - - # Move cursor back to the beginning - printf "${ESC}[%dA" $menu_size - printf "${ESC}7" # Save cursor position again - fi -} - -# Read a single key -read_key() { - local key - IFS= read -rsn1 key 2>/dev/null || return 1 - - case "$key" in - $'\033') - local key2 key3 - if IFS= read -rsn1 -t 0.2 key2 2>/dev/null; then - if [[ "$key2" == "[" ]]; then - if IFS= read -rsn1 -t 0.2 key3 2>/dev/null; then - case "$key3" in - 'A') echo "UP" ;; - 'B') echo "DOWN" ;; - 'C') echo "RIGHT" ;; - 'D') echo "LEFT" ;; - *) echo "OTHER" ;; - esac - else - echo "OTHER" - fi - else - echo "OTHER" - fi - else - echo "OTHER" - fi - ;; - $'\n'|$'\r') echo "ENTER" ;; - ' ') echo " " ;; - 'q'|'Q') echo "QUIT" ;; - *) echo "$key" ;; - esac -} - -# Main menu function -# Usage: show_menu "Title" "option1" "option2" "option3" ... -show_menu() { - local title="$1" - shift - - # Initialize menu options - menu_options=("$@") - menu_size=${#menu_options[@]} - selected=0 - - # Check if we have options - if [[ $menu_size -eq 0 ]]; then - echo "Error: No menu options provided" >&2 - return 1 - fi - - # Setup terminal - setup_terminal - trap restore_terminal EXIT INT TERM - - # Display title - if [[ -n "$title" ]]; then - printf "\n\033[1;34m%s\033[0m\n\n" "$title" - fi - - # Initial draw - draw_menu true - - # Main loop - local first_iteration=true - while true; do - local key=$(read_key) - - case "$key" in - "UP") - ((selected--)) - if [[ $selected -lt 0 ]]; then - selected=$((menu_size - 1)) - fi - draw_menu false # Quick update - ;; - "DOWN") - ((selected++)) - if [[ $selected -ge $menu_size ]]; then - selected=0 - fi - draw_menu false # Quick update - ;; - "ENTER") - # Clear the menu - for ((i = 0; i < menu_size; i++)); do - printf "\r%s\n" "$CLEAR_LINE" >&2 - done - printf "${ESC}[%dA" $menu_size >&2 - - # Show selection - printf "Selected: \033[1;32m%s\033[0m\n\n" "${menu_options[selected]}" - - restore_terminal - return $selected - ;; - "q"|"Q") - restore_terminal - echo "Cancelled." >&2 - return 255 - ;; - [0-9]) - # Jump to numbered option - local num=$((key - 1)) - if [[ $num -ge 0 && $num -lt $menu_size ]]; then - selected=$num - draw_menu - fi - ;; - # Ignore other keys - esac - done -} - -# Multi-select menu function -# Usage: show_multi_menu "Title" "option1" "option2" "option3" ... -show_multi_menu() { - local title="$1" - shift - - # Initialize menu options - menu_options=("$@") - menu_size=${#menu_options[@]} - selected=0 - - # Array to track selected items - declare -a selected_items=() - for ((i = 0; i < menu_size; i++)); do - selected_items[i]=false - done - - # Check if we have options - if [[ $menu_size -eq 0 ]]; then - echo "Error: No menu options provided" >&2 - return 1 - fi - - # Setup terminal - setup_terminal - trap restore_terminal EXIT INT TERM - - # Display title - if [[ -n "$title" ]]; then - printf "\n\033[1;34m%s\033[0m\n" "$title" >&2 - printf "\033[0;36mUse SPACE to select/deselect, ENTER to confirm, Q to quit\033[0m\n\n" >&2 - fi - - # Draw multi-select menu - draw_multi_menu() { - local force_full_redraw="${1:-true}" - printf "%s" "$HIDE_CURSOR" >&2 - - if [[ "$force_full_redraw" == "true" ]]; then - # Full redraw - for ((i = 0; i < menu_size; i++)); do - printf "\r%s" "$CLEAR_LINE" >&2 - - local checkbox="โ˜" - if [[ ${selected_items[i]} == "true" ]]; then - checkbox="\033[1;32mโ˜‘\033[0m" - fi - - if [[ $i -eq $selected ]]; then - printf "โ–ถ %s \033[1;32m%s\033[0m\n" "$checkbox" "${menu_options[i]}" >&2 - else - printf " %s %s\n" "$checkbox" "${menu_options[i]}" >&2 - fi - done - - # Move cursor back to the beginning and save position - printf "${ESC}[%dA" $menu_size >&2 - printf "${ESC}7" >&2 # Save cursor position - else - # Quick update - printf "${ESC}8" >&2 # Restore cursor position - - for ((i = 0; i < menu_size; i++)); do - printf "\r%s" "$CLEAR_LINE" >&2 - - local checkbox="โ˜" - if [[ ${selected_items[i]} == "true" ]]; then - checkbox="\033[1;32mโ˜‘\033[0m" - fi - - if [[ $i -eq $selected ]]; then - printf "โ–ถ %s \033[1;32m%s\033[0m\n" "$checkbox" "${menu_options[i]}" >&2 - else - printf " %s %s\n" "$checkbox" "${menu_options[i]}" >&2 - fi - done - - # Move cursor back to the beginning and save position - printf "${ESC}[%dA" $menu_size >&2 - printf "${ESC}7" >&2 # Save cursor position - fi - } - - # Initial draw - draw_multi_menu true - - # Main loop - while true; do - local key=$(read_key) - - case "$key" in - "UP") - ((selected--)) - if [[ $selected -lt 0 ]]; then - selected=$((menu_size - 1)) - fi - draw_multi_menu false # Quick update - ;; - "DOWN") - ((selected++)) - if [[ $selected -ge $menu_size ]]; then - selected=0 - fi - draw_multi_menu false # Quick update - ;; - " ") - # Toggle selection - if [[ ${selected_items[selected]} == "true" ]]; then - selected_items[selected]="false" - else - selected_items[selected]="true" - fi - draw_multi_menu false # Quick update - ;; - "ENTER") - # Clear the menu - for ((i = 0; i < menu_size; i++)); do - printf "\r%s\n" "$CLEAR_LINE" >&2 - done - printf "${ESC}[%dA" $menu_size >&2 - - # Show selections to stderr so it doesn't interfere with return value - local has_selection=false - printf "Selected items:\n" >&2 - for ((i = 0; i < menu_size; i++)); do - if [[ ${selected_items[i]} == "true" ]]; then - printf " \033[1;32m%s\033[0m\n" "${menu_options[i]}" >&2 - has_selection=true - fi - done - - if [[ $has_selection == "false" ]]; then - printf " None\n" >&2 - fi - printf "\n" >&2 - - restore_terminal - - # Return selected indices as space-separated string - local result="" - for ((i = 0; i < menu_size; i++)); do - if [[ ${selected_items[i]} == "true" ]]; then - result="$result $i" - fi - done - echo "${result# }" # Remove leading space - return 0 - ;; - "q"|"Q"|"ESC") - restore_terminal - echo "Cancelled." >&2 - return 255 - ;; - esac - done -} - -# Example usage function -demo_menu() { - echo "=== Single Select Demo ===" - if show_menu "Choose an action:" "Install package" "Update system" "Clean cache" "Exit"; then - local choice=$? - echo "You selected option $choice" - fi - - echo -e "\n=== Multi Select Demo ===" - local selections=$(show_multi_menu "Choose packages to install:" "git" "vim" "curl" "htop" "tree") - if [[ $? -eq 0 && -n "$selections" ]]; then - echo "Selected indices: $selections" - # Convert indices to actual values - local options=("git" "vim" "curl" "htop" "tree") - echo "Selected packages:" - for idx in $selections; do - echo " - ${options[idx]}" - done - fi -} - -# If script is run directly, show demo -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - demo_menu -fi diff --git a/lib/paginated_menu.sh b/lib/paginated_menu.sh index 608e813..61d860f 100755 --- a/lib/paginated_menu.sh +++ b/lib/paginated_menu.sh @@ -1,64 +1,29 @@ #!/bin/bash +# Paginated menu with arrow key navigation -# Proper paginated menu with arrow key navigation -# 10 items per page, up/down to navigate, space to select, left/right to change pages +set -euo pipefail # Terminal control functions -hide_cursor() { printf '\033[?25l' >&2; } -show_cursor() { printf '\033[?25h' >&2; } -clear_screen() { printf '\033[2J\033[H' >&2; } -enter_alt_screen() { tput smcup >/dev/null 2>&1 || true; } -leave_alt_screen() { tput rmcup >/dev/null 2>&1 || true; } -disable_wrap() { printf '\033[?7l' >&2; } # disable line wrap -enable_wrap() { printf '\033[?7h' >&2; } +enter_alt_screen() { tput smcup 2>/dev/null || true; } +leave_alt_screen() { tput rmcup 2>/dev/null || true; } -# Read single key with arrow key support (macOS bash 3.2 friendly) -read_key() { - local key seq - IFS= read -rsn1 key || return 1 - - # Some terminals may yield empty on Enter with -n1 - if [[ -z "$key" ]]; then - echo "ENTER" - return 0 - fi - - case "$key" in - $'\033') - # Read next two bytes within 1s: "[A", "[B", ... - if IFS= read -rsn2 -t 1 seq 2>/dev/null; then - case "$seq" in - "[A") echo "UP" ;; - "[B") echo "DOWN" ;; - "[C") echo "RIGHT" ;; - "[D") echo "LEFT" ;; - *) echo "OTHER" ;; - esac - else - echo "OTHER" - fi - ;; - ' ') echo "SPACE" ;; - $'\n'|$'\r') echo "ENTER" ;; - 'q'|'Q') echo "QUIT" ;; - 'a'|'A') echo "ALL" ;; - 'n'|'N') echo "NONE" ;; - '?') echo "HELP" ;; - *) echo "OTHER" ;; - esac -} - -# Paginated multi-select menu +# Main paginated multi-select menu function paginated_multi_select() { local title="$1" shift local -a items=("$@") + # Validation + if [[ ${#items[@]} -eq 0 ]]; then + echo "No items provided" >&2 + return 1 + fi + local total_items=${#items[@]} - local items_per_page=10 # Reduced for better readability + local items_per_page=10 local total_pages=$(( (total_items + items_per_page - 1) / items_per_page )) local current_page=0 - local cursor_pos=0 # Position within current page (0-9) + local cursor_pos=0 local -a selected=() # Initialize selection array @@ -69,78 +34,60 @@ paginated_multi_select() { # Cleanup function cleanup() { show_cursor - stty echo 2>/dev/null || true - stty icanon 2>/dev/null || true + stty echo icanon 2>/dev/null || true leave_alt_screen - enable_wrap } trap cleanup EXIT INT TERM - # Setup terminal for optimal responsiveness - stty -echo -icanon min 1 time 0 2>/dev/null || true + # Setup terminal + stty -echo -icanon 2>/dev/null || true enter_alt_screen - disable_wrap hide_cursor - # Main display function - first_draw=1 - # Helper: print one cleared line - print_line() { - printf "\r\033[2K%s\n" "$1" >&2 - } + # Helper functions + print_line() { printf "\r\033[2K%s\n" "$1" >&2; } - # Helper: render one item line at given page position - render_item_line() { - local page_pos=$1 - local start_idx=$((current_page * items_per_page)) - local i=$((start_idx + page_pos)) + render_item() { + local idx=$1 is_current=$2 local checkbox="โ˜" - local cursor_marker=" " - [[ ${selected[i]} == true ]] && checkbox="โ˜‘" - if [[ $page_pos -eq $cursor_pos ]]; then - cursor_marker="โ–ถ " - printf "\r\033[2K\033[7m%s%s %s\033[0m\n" "$cursor_marker" "$checkbox" "${items[i]}" >&2 + [[ ${selected[idx]} == true ]] && checkbox="โ˜‘" + + if [[ $is_current == true ]]; then + printf "\r\033[2K\033[7mโ–ถ %s %s\033[0m\n" "$checkbox" "${items[idx]}" >&2 else - printf "\r\033[2K%s%s %s\n" "$cursor_marker" "$checkbox" "${items[i]}" >&2 + printf "\r\033[2K %s %s\n" "$checkbox" "${items[idx]}" >&2 fi } - # Helper: move cursor to top-left anchor saved by tput sc - to_anchor() { tput rc >/dev/null 2>&1 || true; } - - # Full draw of entire screen - simplified for stability + # Draw the complete menu draw_menu() { - # Always do full screen redraw for reliability - clear_screen + printf "\033[H\033[J" >&2 # Clear screen and move to top - # Simple header - printf "%s\n" "$title" >&2 - printf "%s\n" "$(printf '=%.0s' $(seq 1 ${#title}))" >&2 + # Header + printf "%s\n%s\n" "$title" "$(printf '=%.0s' $(seq 1 ${#title}))" >&2 - # Status bar + # Status local selected_count=0 for ((i = 0; i < total_items; i++)); do [[ ${selected[i]} == true ]] && ((selected_count++)) done - - printf "Page %d/%d โ”‚ Total: %d โ”‚ Selected: %d\n" \ + printf "Page %d/%d โ”‚ Total: %d โ”‚ Selected: %d\n\n" \ $((current_page + 1)) $total_pages $total_items $selected_count >&2 - print_line "" - # Calculate page boundaries + # Items for current page local start_idx=$((current_page * items_per_page)) local end_idx=$((start_idx + items_per_page - 1)) [[ $end_idx -ge $total_items ]] && end_idx=$((total_items - 1)) - # Display items for current page for ((i = start_idx; i <= end_idx; i++)); do - local page_pos=$((i - start_idx)) - render_item_line "$page_pos" + local is_current=false + [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true + render_item $i $is_current done - # Fill empty slots to always print items_per_page lines - local items_on_page=$((end_idx - start_idx + 1)) - for ((i = items_on_page; i < items_per_page; i++)); do + # Fill empty slots + local items_shown=$((end_idx - start_idx + 1)) + for ((i = items_shown; i < items_per_page; i++)); do print_line "" done @@ -148,45 +95,42 @@ paginated_multi_select() { print_line "โ†‘โ†“: Navigate | Space: Select | Enter: Confirm | Q: Exit" } - # Help screen + # Show help screen show_help() { - clear_screen - echo "App Uninstaller - Help" >&2 - echo "======================" >&2 - echo >&2 - echo " โ†‘ / โ†“ Navigate up/down" >&2 - echo " โ† / โ†’ Previous/next page" >&2 - echo " Space Select/deselect app" >&2 - echo " Enter Confirm selection" >&2 - echo " A Select all" >&2 - echo " N Deselect all" >&2 - echo " Q Exit" >&2 - echo >&2 - read -p "Press any key to continue..." -n 1 >&2 + printf "\033[H\033[J" >&2 + cat >&2 << 'EOF' +Help - Navigation Controls +========================== + + โ†‘ / โ†“ Navigate up/down + Space Select/deselect item + Enter Confirm selection + A Select all + N Deselect all + Q Exit + +Press any key to continue... +EOF + read -n 1 -s >&2 } - # Main loop - simplified to always do full redraws for stability + # Main interaction loop while true; do - draw_menu # Always full redraw to avoid display issues - + draw_menu local key=$(read_key) - # Immediate exit key - if [[ "$key" == "QUIT" ]]; then - cleanup - return 1 - fi - case "$key" in + "QUIT") cleanup; return 1 ;; "UP") if [[ $cursor_pos -gt 0 ]]; then ((cursor_pos--)) elif [[ $current_page -gt 0 ]]; then ((current_page--)) - cursor_pos=$((items_per_page - 1)) + # Calculate cursor position for new page local start_idx=$((current_page * items_per_page)) - local end_idx=$((start_idx + items_per_page - 1)) - [[ $end_idx -ge $total_items ]] && cursor_pos=$((total_items - start_idx - 1)) + local items_on_page=$((total_items - start_idx)) + [[ $items_on_page -gt $items_per_page ]] && items_on_page=$items_per_page + cursor_pos=$((items_on_page - 1)) fi ;; "DOWN") @@ -201,33 +145,13 @@ paginated_multi_select() { cursor_pos=0 fi ;; - "LEFT") - if [[ $current_page -gt 0 ]]; then - ((current_page--)) - cursor_pos=0 - fi - ;; - "RIGHT") - if [[ $current_page -lt $((total_pages - 1)) ]]; then - ((current_page++)) - cursor_pos=0 - fi - ;; - "PGUP") - current_page=0 - cursor_pos=0 - ;; - "PGDOWN") - current_page=$((total_pages - 1)) - cursor_pos=0 - ;; "SPACE") - local actual_idx=$((current_page * items_per_page + cursor_pos)) - if [[ $actual_idx -lt $total_items ]]; then - if [[ ${selected[actual_idx]} == true ]]; then - selected[actual_idx]=false + local idx=$((current_page * items_per_page + cursor_pos)) + if [[ $idx -lt $total_items ]]; then + if [[ ${selected[idx]} == true ]]; then + selected[idx]=false else - selected[actual_idx]=true + selected[idx]=true fi fi ;; @@ -241,11 +165,9 @@ paginated_multi_select() { selected[i]=false done ;; - "HELP") - show_help - ;; + "HELP") show_help ;; "ENTER") - # If no items are selected, select the current item + # Auto-select current item if nothing selected local has_selection=false for ((i = 0; i < total_items; i++)); do if [[ ${selected[i]} == true ]]; then @@ -255,58 +177,38 @@ paginated_multi_select() { done if [[ $has_selection == false ]]; then - # Select current item under cursor - local actual_idx=$((current_page * items_per_page + cursor_pos)) - if [[ $actual_idx -lt $total_items ]]; then - selected[actual_idx]=true - fi + local idx=$((current_page * items_per_page + cursor_pos)) + [[ $idx -lt $total_items ]] && selected[idx]=true fi - # Build result + # Store result in global variable instead of returning via stdout local result="" for ((i = 0; i < total_items; i++)); do if [[ ${selected[i]} == true ]]; then result="$result $i" fi done - cleanup - echo "${result# }" + local final_result="${result# }" + + # Remove the trap to avoid cleanup on normal exit + trap - EXIT INT TERM + + # Store result in global variable + MOLE_SELECTION_RESULT="$final_result" + + # Manually cleanup terminal before returning + show_cursor + stty echo icanon 2>/dev/null || true + leave_alt_screen + return 0 ;; - *) - # Ignore unrecognized keys - just continue the loop - ;; esac done } -# Demo function -demo_paginated() { - echo "=== Paginated Multi-select Demo ===" >&2 - - # Create test data - local test_items=() - for i in {1..35}; do - test_items+=("Application $i ($(( (RANDOM % 500) + 50 ))MB)") - done - - local result - result=$(paginated_multi_select "Choose Applications to Uninstall" "${test_items[@]}") - local exit_code=$? - - if [[ $exit_code -eq 0 ]]; then - if [[ -n "$result" ]]; then - echo "Selected indices: $result" >&2 - echo "Count: $(echo $result | wc -w | tr -d ' ')" >&2 - else - echo "No items selected" >&2 - fi - else - echo "Selection cancelled" >&2 - fi -} - -# Run demo if script is executed directly +# Export function for external use if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - demo_paginated + echo "This is a library file. Source it from other scripts." >&2 + exit 1 fi diff --git a/mole b/mole index bf027a2..13db5fa 100755 --- a/mole +++ b/mole @@ -24,8 +24,12 @@ VERSION="1.0.0" MOLE_TAGLINE="Dig deep like a mole to clean your Mac." show_brand_banner() { - printf '%b๐Ÿฆก %bMOLE%b โ€” %b%s%b\n' \ - "$PURPLE" "$BLUE" "$NC" "$GREEN" "$MOLE_TAGLINE" "$NC" + printf '%b๐Ÿฆก Mole โ€” %s%b\n' \ + "$GREEN" "$MOLE_TAGLINE" "$NC" +} + +show_version() { + printf 'Mole version %s\n' "$VERSION" } show_help() { @@ -38,115 +42,142 @@ show_help() { printf " %s%-16s%s %s\n" "$GREEN" "mole" "$NC" "Interactive main menu" printf " %s%-16s%s %s\n" "$GREEN" "mole clean" "$NC" "Deeper system cleanup" printf " %s%-16s%s %s\n" "$GREEN" "mole uninstall" "$NC" "Remove applications completely" + printf " %s%-16s%s %s\n" "$GREEN" "mole update" "$NC" "Update Mole to the latest version" + printf " %s%-16s%s %s\n" "$GREEN" "mole --version" "$NC" "Show installed version" printf " %s%-16s%s %s\n" "$GREEN" "mole --help" "$NC" "Show this help message" printf "\n%s%s%s\n" "$BLUE" "MORE" "$NC" - printf " https://github.com/tw93/mole\n" + printf " https://github.com/tw93/mole\n\n" } +# Simple update function +update_mole() { + log_info "Updating Mole..." + + local installer_url="https://raw.githubusercontent.com/tw93/mole/main/install.sh" + local tmp_installer + tmp_installer="$(mktemp)" || { log_error "Failed to create temp file"; exit 1; } + + # Download installer + if command -v curl >/dev/null 2>&1; then + if ! curl -fsSL "$installer_url" -o "$tmp_installer"; then + rm -f "$tmp_installer" + log_error "Failed to download installer" + exit 1 + fi + elif command -v wget >/dev/null 2>&1; then + if ! wget -qO "$tmp_installer" "$installer_url"; then + rm -f "$tmp_installer" + log_error "Failed to download installer" + exit 1 + fi + else + rm -f "$tmp_installer" + log_error "Please install curl or wget to update Mole" + exit 1 + fi + + chmod +x "$tmp_installer" + + # Determine install directory + local mole_path + mole_path="$(command -v mole 2>/dev/null || echo "$0")" + local install_dir + install_dir="$(cd "$(dirname "$mole_path")" && pwd)" + + # Run installer + if "$tmp_installer" --prefix "$install_dir" --config "$HOME/.config/mole" --update 2>/dev/null; then + log_success "Mole updated successfully" + else + log_warning "Update failed, trying reinstall..." + if "$tmp_installer" --prefix "$install_dir" --config "$HOME/.config/mole"; then + log_success "Mole reinstalled successfully" + else + rm -f "$tmp_installer" + log_error "Update failed" + exit 1 + fi + fi + + rm -f "$tmp_installer" +} + +# Display main menu options show_main_menu() { local selected="${1:-1}" - local redraw_full="${2:-true}" + local full_draw="${2:-true}" - if [[ "$redraw_full" == "true" ]]; then + if [[ "$full_draw" == "true" ]]; then echo "" show_brand_banner echo "" fi - # Save cursor position before printing menu items - printf '\033[s' + printf '\033[s' # Save cursor position show_menu_option 1 "Clean System - Remove junk files and optimize" "$([[ $selected -eq 1 ]] && echo true || echo false)" show_menu_option 2 "Uninstall Apps - Remove applications completely" "$([[ $selected -eq 2 ]] && echo true || echo false)" show_menu_option 3 "Help & Information - Usage guide and tips" "$([[ $selected -eq 3 ]] && echo true || echo false)" show_menu_option 4 "Exit - Close Mole" "$([[ $selected -eq 4 ]] && echo true || echo false)" - if [[ "$redraw_full" == "true" ]]; then + if [[ "$full_draw" == "true" ]]; then echo "" - echo -e "${BLUE}Use โ†‘/โ†“ arrows to navigate, ENTER to select, ESC/q to quit${NC}" + echo -e "${BLUE}โ†‘/โ†“ to navigate, ENTER to select, Q to quit${NC}" fi } +# Interactive main menu loop interactive_main_menu() { local current_option=1 - local total_options=4 local first_draw=true - # Set up signal trapping to handle Ctrl+C gracefully - trap 'echo ""; echo "Thank you for using Mole!"; exit 0' INT - - cleanup_menu_display() { - printf '\033[u\033[J' + cleanup_and_exit() { + printf '\033[u\033[J' # Restore cursor and clear + show_cursor + echo "" + echo "Thank you for using Mole!" + exit 0 } + trap cleanup_and_exit INT + hide_cursor + while true; do if [[ "$first_draw" == "true" ]]; then show_main_menu $current_option true first_draw=false else - # Restore cursor to saved position and only redraw menu items - printf '\033[u' + printf '\033[u' # Restore cursor position show_main_menu $current_option false fi - # Use a more robust way to capture key input - local key - key=$(read_key) - - # Check if read_key failed - if [[ $? -ne 0 ]]; then - continue - fi + local key=$(read_key) + [[ $? -ne 0 ]] && continue case "$key" in - "UP") - ((current_option > 1)) && ((current_option--)) - ;; - "DOWN") - ((current_option < total_options)) && ((current_option++)) - ;; - "ENTER") - cleanup_menu_display + "UP") ((current_option > 1)) && ((current_option--)) ;; + "DOWN") ((current_option < 4)) && ((current_option++)) ;; + "ENTER"|"$current_option") + printf '\033[u\033[J' # Clear menu + show_cursor case $current_option in - 1) - exec "$SCRIPT_DIR/bin/clean.sh" - ;; - 2) - exec "$SCRIPT_DIR/bin/uninstall.sh" - ;; - 3) - clear_screen - show_help - exit 0 - ;; - 4) - clear_screen - echo "" - echo "Thank you for using Mole!" - exit 0 - ;; + 1) exec "$SCRIPT_DIR/bin/clean.sh" ;; + 2) exec "$SCRIPT_DIR/bin/uninstall.sh" ;; + 3) clear; show_help; exit 0 ;; + 4) cleanup_and_exit ;; esac ;; - "QUIT"|"ESC") - cleanup_menu_display - clear_screen - echo "" - echo "Thank you for using Mole!" - exit 0 - ;; - "1"|"2"|"3"|"4") - cleanup_menu_display + "QUIT") cleanup_and_exit ;; + [1-4]) + printf '\033[u\033[J' + show_cursor case $key in 1) exec "$SCRIPT_DIR/bin/clean.sh" ;; 2) exec "$SCRIPT_DIR/bin/uninstall.sh" ;; - 3) clear_screen; show_help; exit 0 ;; - 4) clear_screen; echo ""; echo "Thank you for using Mole!"; exit 0 ;; + 3) clear; show_help; exit 0 ;; + 4) cleanup_and_exit ;; esac ;; - *) - # Ignore unrecognized keys - ;; esac done } @@ -159,10 +190,18 @@ main() { "uninstall") exec "$SCRIPT_DIR/bin/uninstall.sh" ;; + "update") + update_mole + exit 0 + ;; "help"|"--help"|"-h") show_help exit 0 ;; + "version"|"--version"|"-V") + show_version + exit 0 + ;; "") interactive_main_menu ;;