From 80e3be710b5c66d4efd8c75d33113ac6e414a2eb Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 8 Dec 2025 15:33:52 +0800 Subject: [PATCH] Restructure common split content --- CONTRIBUTING.md | 99 +- lib/core/app_protection.sh | 622 ++++++++++ lib/core/base.sh | 339 ++++++ lib/core/common.sh | 2241 +----------------------------------- lib/core/file_ops.sh | 291 +++++ lib/core/log.sh | 153 +++ lib/core/sudo.sh | 163 +++ lib/core/timeout.sh | 156 +++ lib/core/ui.sh | 171 +++ 9 files changed, 2021 insertions(+), 2214 deletions(-) create mode 100755 lib/core/app_protection.sh create mode 100644 lib/core/base.sh create mode 100644 lib/core/file_ops.sh create mode 100644 lib/core/log.sh create mode 100644 lib/core/timeout.sh create mode 100755 lib/core/ui.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad761fb..be60c0b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,14 +33,105 @@ Individual commands: ## Code Style -- Bash 3.2+ compatible +### Basic Rules + +- Bash 3.2+ compatible (macOS default) - 4 spaces indent -- Use `set -euo pipefail` -- Quote all variables -- BSD commands not GNU +- Use `set -euo pipefail` in all scripts +- Quote all variables: `"$variable"` +- Use `[[ ]]` not `[ ]` for tests +- Use `local` for function variables, `readonly` for constants +- Function names: `snake_case` +- BSD commands not GNU (e.g., `stat -f%z` not `stat --format`) Config: `.editorconfig` and `.shellcheckrc` +### File Operations + +**Always use safe wrappers, never `rm -rf` directly:** + +```bash +# Single file/directory +safe_remove "/path/to/file" + +# Batch delete with find +safe_find_delete "$dir" "*.log" 7 "f" # files older than 7 days + +# With sudo +safe_sudo_remove "/Library/Caches/com.example" +``` + +See `lib/core/file_ops.sh` for all safe functions. + +### Pipefail Safety + +All commands that might fail must be handled: + +```bash +# Correct: handle failure +find /nonexistent -name "*.cache" 2>/dev/null || true + +# Correct: check array before use +if [[ ${#array[@]} -gt 0 ]]; then + for item in "${array[@]}"; do + process "$item" + done +fi + +# Correct: arithmetic operations +((count++)) || true +``` + +### Error Handling + +```bash +# Network requests with timeout +result=$(curl -fsSL --connect-timeout 2 --max-time 3 "$url" 2>/dev/null || echo "") + +# Command existence check +if ! command -v brew >/dev/null 2>&1; then + log_warning "Homebrew not installed" + return 0 +fi +``` + +### UI and Logging + +```bash +# Logging +log_info "Starting cleanup" +log_success "Cache cleaned" +log_warning "Some files skipped" +log_error "Operation failed" + +# Spinners +with_spinner "Cleaning cache" rm -rf "$cache_dir" + +# Or inline +start_inline_spinner "Processing..." +# ... work ... +stop_inline_spinner "Complete" +``` + +### Debug Mode + +Enable debug output with `--debug`: + +```bash +mo --debug clean +./bin/clean.sh --debug +``` + +Modules check the internal `MO_DEBUG` variable: + +```bash +if [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo "[MODULE] Debug message" >&2 +fi +``` + +Format: `[MODULE_NAME] message` output to stderr. + ## Requirements - macOS 10.14 or newer, works on Intel and Apple Silicon diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh new file mode 100755 index 0000000..ff5abb5 --- /dev/null +++ b/lib/core/app_protection.sh @@ -0,0 +1,622 @@ +#!/bin/bash +# Mole - Application Protection +# System critical and data-protected application lists + +set -euo pipefail + +if [[ -n "${MOLE_APP_PROTECTION_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_APP_PROTECTION_LOADED=1 + +_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[[ -z "${MOLE_BASE_LOADED:-}" ]] && source "$_MOLE_CORE_DIR/base.sh" + +# ============================================================================ +# App Management Functions +# ============================================================================ + +# System critical components that should NEVER be uninstalled +readonly SYSTEM_CRITICAL_BUNDLES=( + "com.apple.*" # System essentials + "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.QQInput" + "com.sogou.inputmethod.*" + "com.baidu.inputmethod.*" + "com.apple.inputmethod.*" + "com.googlecode.rimeime.*" + "im.rime.*" + "org.pqrs.Karabiner*" + "*.inputmethod" + "*.InputMethod" + "*IME" + "com.apple.inputsource*" + "com.apple.TextInputMenuAgent" + "com.apple.TextInputSwitcher" +) + +# Apps with important data/licenses - protect during cleanup but allow uninstall +readonly DATA_PROTECTED_BUNDLES=( + # ============================================================================ + # System Utilities & Cleanup Tools + # ============================================================================ + "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 + + # ============================================================================ + # Password Managers & Security + # ============================================================================ + "com.1password.*" # 1Password + "com.agilebits.*" # 1Password legacy + "com.lastpass.*" # LastPass + "com.dashlane.*" # Dashlane + "com.bitwarden.*" # Bitwarden + "com.keepassx.*" # KeePassXC + "org.keepassx.*" # KeePassX + "com.authy.*" # Authy + "com.yubico.*" # YubiKey Manager + + # ============================================================================ + # Development Tools - IDEs & Editors + # ============================================================================ + "com.jetbrains.*" # JetBrains IDEs (IntelliJ, DataGrip, etc.) + "JetBrains*" # JetBrains Application Support folders + "com.microsoft.VSCode" # Visual Studio Code + "com.visualstudio.code.*" # VS Code variants + "com.sublimetext.*" # Sublime Text + "com.sublimehq.*" # Sublime Merge + "com.microsoft.VSCodeInsiders" # VS Code Insiders + "com.apple.dt.Xcode" # Xcode (keep settings) + "com.coteditor.CotEditor" # CotEditor + "com.macromates.TextMate" # TextMate + "com.panic.Nova" # Nova + "abnerworks.Typora" # Typora (Markdown editor) + "com.uranusjr.macdown" # MacDown + + # ============================================================================ + # Development Tools - Database Clients + # ============================================================================ + "com.sequelpro.*" # Sequel Pro + "com.sequel-ace.*" # Sequel Ace + "com.tinyapp.*" # TablePlus + "com.dbeaver.*" # DBeaver + "com.navicat.*" # Navicat + "com.mongodb.compass" # MongoDB Compass + "com.redis.RedisInsight" # Redis Insight + "com.pgadmin.pgadmin4" # pgAdmin + "com.eggerapps.Sequel-Pro" # Sequel Pro legacy + "com.valentina-db.Valentina-Studio" # Valentina Studio + "com.dbvis.DbVisualizer" # DbVisualizer + + # ============================================================================ + # Development Tools - API & Network + # ============================================================================ + "com.postmanlabs.mac" # Postman + "com.konghq.insomnia" # Insomnia + "com.CharlesProxy.*" # Charles Proxy + "com.proxyman.*" # Proxyman + "com.getpaw.*" # Paw + "com.luckymarmot.Paw" # Paw legacy + "com.charlesproxy.charles" # Charles + "com.telerik.Fiddler" # Fiddler + "com.usebruno.app" # Bruno (API client) + + # Network Proxy & VPN Tools (protect all variants) + "*clash*" # All Clash variants (ClashX, ClashX Pro, Clash Verge, etc) + "*Clash*" # Capitalized variants + "com.nssurge.surge-mac" # Surge + "mihomo*" # Mihomo Party and variants + "*openvpn*" # OpenVPN Connect and variants + "*OpenVPN*" # OpenVPN capitalized variants + "net.openvpn.*" # OpenVPN bundle IDs + + # ============================================================================ + # Development Tools - Git & Version Control + # ============================================================================ + "com.github.GitHubDesktop" # GitHub Desktop + "com.sublimemerge" # Sublime Merge + "com.torusknot.SourceTreeNotMAS" # SourceTree + "com.git-tower.Tower*" # Tower + "com.gitfox.GitFox" # GitFox + "com.github.Gitify" # Gitify + "com.fork.Fork" # Fork + "com.axosoft.gitkraken" # GitKraken + + # ============================================================================ + # Development Tools - Terminal & Shell + # ============================================================================ + "com.googlecode.iterm2" # iTerm2 + "net.kovidgoyal.kitty" # Kitty + "io.alacritty" # Alacritty + "com.github.wez.wezterm" # WezTerm + "com.hyper.Hyper" # Hyper + "com.mizage.divvy" # Divvy + "com.fig.Fig" # Fig (terminal assistant) + "dev.warp.Warp-Stable" # Warp + "com.termius-dmg" # Termius (SSH client) + + # ============================================================================ + # Development Tools - Docker & Virtualization + # ============================================================================ + "com.docker.docker" # Docker Desktop + "com.getutm.UTM" # UTM + "com.vmware.fusion" # VMware Fusion + "com.parallels.desktop.*" # Parallels Desktop + "org.virtualbox.app.VirtualBox" # VirtualBox + "com.vagrant.*" # Vagrant + "com.orbstack.OrbStack" # OrbStack + + # ============================================================================ + # System Monitoring & Performance + # ============================================================================ + "com.bjango.istatmenus*" # iStat Menus + "eu.exelban.Stats" # Stats + "com.monitorcontrol.*" # MonitorControl + "com.bresink.system-toolkit.*" # TinkerTool System + "com.mediaatelier.MenuMeters" # MenuMeters + "com.activity-indicator.app" # Activity Indicator + "net.cindori.sensei" # Sensei + + # ============================================================================ + # Window Management & Productivity + # ============================================================================ + "com.macitbetter.*" # BetterTouchTool, BetterSnapTool + "com.hegenberg.*" # BetterTouchTool legacy + "com.manytricks.*" # Moom, Witch, Name Mangler, Resolutionator + "com.divisiblebyzero.*" # Spectacle + "com.koingdev.*" # Koingg apps + "com.if.Amphetamine" # Amphetamine + "com.lwouis.alt-tab-macos" # AltTab + "net.matthewpalmer.Vanilla" # Vanilla + "com.lightheadsw.Caffeine" # Caffeine + "com.contextual.Contexts" # Contexts + "com.amethyst.Amethyst" # Amethyst + "com.knollsoft.Rectangle" # Rectangle + "com.knollsoft.Hookshot" # Hookshot + "com.surteesstudios.Bartender" # Bartender + "com.gaosun.eul" # eul (system monitor) + "com.pointum.hazeover" # HazeOver + + # ============================================================================ + # Launcher & Automation + # ============================================================================ + "com.runningwithcrayons.Alfred" # Alfred + "com.raycast.macos" # Raycast + "com.blacktree.Quicksilver" # Quicksilver + "com.stairways.keyboardmaestro.*" # Keyboard Maestro + "com.manytricks.Butler" # Butler + "com.happenapps.Quitter" # Quitter + "com.pilotmoon.scroll-reverser" # Scroll Reverser + "org.pqrs.Karabiner-Elements" # Karabiner-Elements + "com.apple.Automator" # Automator (system, but keep user workflows) + + # ============================================================================ + # Note-Taking & Documentation + # ============================================================================ + "com.bear-writer.*" # Bear + "com.typora.*" # Typora + "com.ulyssesapp.*" # Ulysses + "com.literatureandlatte.*" # Scrivener + "com.dayoneapp.*" # Day One + "notion.id" # Notion + "md.obsidian" # Obsidian + "com.logseq.logseq" # Logseq + "com.evernote.Evernote" # Evernote + "com.onenote.mac" # OneNote + "com.omnigroup.OmniOutliner*" # OmniOutliner + "net.shinyfrog.bear" # Bear legacy + "com.goodnotes.GoodNotes" # GoodNotes + "com.marginnote.MarginNote*" # MarginNote + "com.roamresearch.*" # Roam Research + "com.reflect.ReflectApp" # Reflect + "com.inkdrop.*" # Inkdrop + + # ============================================================================ + # Design & Creative Tools + # ============================================================================ + "com.adobe.*" # Adobe Creative Suite + "com.bohemiancoding.*" # Sketch + "com.figma.*" # Figma + "com.framerx.*" # Framer + "com.zeplin.*" # Zeplin + "com.invisionapp.*" # InVision + "com.principle.*" # Principle + "com.pixelmatorteam.*" # Pixelmator + "com.affinitydesigner.*" # Affinity Designer + "com.affinityphoto.*" # Affinity Photo + "com.affinitypublisher.*" # Affinity Publisher + "com.linearity.curve" # Linearity Curve + "com.canva.CanvaDesktop" # Canva + "com.maxon.cinema4d" # Cinema 4D + "com.autodesk.*" # Autodesk products + "com.sketchup.*" # SketchUp + + # ============================================================================ + # Communication & Collaboration + # ============================================================================ + "com.tencent.xinWeChat" # WeChat (Chinese users) + "com.tencent.qq" # QQ + "com.alibaba.DingTalkMac" # DingTalk + "com.alibaba.AliLang.osx" # AliLang (retain login/config data) + "com.alibaba.alilang3.osx.ShipIt" # AliLang updater component + "com.alibaba.AlilangMgr.QueryNetworkInfo" # AliLang network helper + "us.zoom.xos" # Zoom + "com.microsoft.teams*" # Microsoft Teams + "com.slack.Slack" # Slack + "com.hnc.Discord" # Discord + "org.telegram.desktop" # Telegram + "ru.keepcoder.Telegram" # Telegram legacy + "net.whatsapp.WhatsApp" # WhatsApp + "com.skype.skype" # Skype + "com.cisco.webexmeetings" # Webex + "com.ringcentral.RingCentral" # RingCentral + "com.readdle.smartemail-Mac" # Spark Email + "com.airmail.*" # Airmail + "com.postbox-inc.postbox" # Postbox + "com.tinyspeck.slackmacgap" # Slack legacy + + # ============================================================================ + # Task Management & Productivity + # ============================================================================ + "com.omnigroup.OmniFocus*" # OmniFocus + "com.culturedcode.*" # Things + "com.todoist.*" # Todoist + "com.any.do.*" # Any.do + "com.ticktick.*" # TickTick + "com.microsoft.to-do" # Microsoft To Do + "com.trello.trello" # Trello + "com.asana.nativeapp" # Asana + "com.clickup.*" # ClickUp + "com.monday.desktop" # Monday.com + "com.airtable.airtable" # Airtable + "com.notion.id" # Notion (also note-taking) + "com.linear.linear" # Linear + + # ============================================================================ + # File Transfer & Sync + # ============================================================================ + "com.panic.transmit*" # Transmit (FTP/SFTP) + "com.binarynights.ForkLift*" # ForkLift + "com.noodlesoft.Hazel" # Hazel + "com.cyberduck.Cyberduck" # Cyberduck + "io.filezilla.FileZilla" # FileZilla + "com.apple.Xcode.CloudDocuments" # Xcode Cloud Documents + "com.synology.*" # Synology apps + + # ============================================================================ + # Screenshot & Recording + # ============================================================================ + "com.cleanshot.*" # CleanShot X + "com.xnipapp.xnip" # Xnip + "com.reincubate.camo" # Camo + "com.tunabellysoftware.ScreenFloat" # ScreenFloat + "net.telestream.screenflow*" # ScreenFlow + "com.techsmith.snagit*" # Snagit + "com.techsmith.camtasia*" # Camtasia + "com.obsidianapp.screenrecorder" # Screen Recorder + "com.kap.Kap" # Kap + "com.getkap.*" # Kap legacy + "com.linebreak.CloudApp" # CloudApp + "com.droplr.droplr-mac" # Droplr + + # ============================================================================ + # Media & Entertainment + # ============================================================================ + "com.spotify.client" # Spotify + "com.apple.Music" # Apple Music + "com.apple.podcasts" # Apple Podcasts + "com.apple.FinalCutPro" # Final Cut Pro + "com.apple.Motion" # Motion + "com.apple.Compressor" # Compressor + "com.blackmagic-design.*" # DaVinci Resolve + "com.colliderli.iina" # IINA + "org.videolan.vlc" # VLC + "io.mpv" # MPV + "com.noodlesoft.Hazel" # Hazel (automation) + "tv.plex.player.desktop" # Plex + "com.netease.163music" # NetEase Music + + # ============================================================================ + # License Management & App Stores + # ============================================================================ + "com.paddle.Paddle*" # Paddle (license management) + "com.setapp.DesktopClient" # Setapp + "com.devmate.*" # DevMate (license framework) + "org.sparkle-project.Sparkle" # Sparkle (update framework) +) + +# Legacy function - preserved for backward compatibility +# Use should_protect_from_uninstall() or should_protect_data() instead +readonly PRESERVED_BUNDLE_PATTERNS=("${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}") + +# Check whether a bundle ID matches a pattern (supports globs) +bundle_matches_pattern() { + local bundle_id="$1" + local pattern="$2" + + [[ -z "$pattern" ]] && return 1 + + # Use bash [[ ]] for glob pattern matching (works with variables in bash 3.2+) + # shellcheck disable=SC2053 # allow glob pattern matching + if [[ "$bundle_id" == $pattern ]]; then + return 0 + fi + return 1 +} + +# Check if app is a system component that should never be uninstalled +should_protect_from_uninstall() { + local bundle_id="$1" + for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do + if bundle_matches_pattern "$bundle_id" "$pattern"; then + return 0 + fi + done + return 1 +} + +# Check if app data should be protected during cleanup (but app can be uninstalled) +should_protect_data() { + local bundle_id="$1" + # Protect both system critical and data protected bundles during cleanup + for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do + if bundle_matches_pattern "$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=() + + # ============================================================================ + # User-level files (no sudo required) + # ============================================================================ + + # 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") + [[ -d ~/Library/Caches/"$app_name" ]] && files_to_clean+=("$HOME/Library/Caches/$app_name") + + # Preferences + [[ -f ~/Library/Preferences/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id.plist") + [[ -d ~/Library/Preferences/ByHost ]] && while IFS= read -r -d '' pref; do + files_to_clean+=("$pref") + done < <(find ~/Library/Preferences/ByHost \( -name \"$bundle_id*.plist\" \) -print0 2> /dev/null) + + # 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 + [[ -d ~/Library/Group\ Containers ]] && while IFS= read -r -d '' container; do + files_to_clean+=("$container") + done < <(find ~/Library/Group\ Containers -type d \( -name \"*$bundle_id*\" \) -print0 2> /dev/null) + + # WebKit data + [[ -d ~/Library/WebKit/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/WebKit/$bundle_id") + [[ -d ~/Library/WebKit/com.apple.WebKit.WebContent/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/WebKit/com.apple.WebKit.WebContent/$bundle_id") + + # HTTP Storage + [[ -d ~/Library/HTTPStorages/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/HTTPStorages/$bundle_id") + + # Cookies + [[ -f ~/Library/Cookies/"$bundle_id".binarycookies ]] && files_to_clean+=("$HOME/Library/Cookies/$bundle_id.binarycookies") + + # Launch Agents (user-level) + [[ -f ~/Library/LaunchAgents/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/LaunchAgents/$bundle_id.plist") + + # Application Scripts + [[ -d ~/Library/Application\ Scripts/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Application Scripts/$bundle_id") + + # Services + [[ -d ~/Library/Services/"$app_name".workflow ]] && files_to_clean+=("$HOME/Library/Services/$app_name.workflow") + + # QuickLook Plugins + [[ -d ~/Library/QuickLook/"$app_name".qlgenerator ]] && files_to_clean+=("$HOME/Library/QuickLook/$app_name.qlgenerator") + + # Preference Panes + [[ -d ~/Library/PreferencePanes/"$app_name".prefPane ]] && files_to_clean+=("$HOME/Library/PreferencePanes/$app_name.prefPane") + + # Screen Savers + [[ -d ~/Library/Screen\ Savers/"$app_name".saver ]] && files_to_clean+=("$HOME/Library/Screen Savers/$app_name.saver") + + # Frameworks + [[ -d ~/Library/Frameworks/"$app_name".framework ]] && files_to_clean+=("$HOME/Library/Frameworks/$app_name.framework") + + # Autosave Information + [[ -d ~/Library/Autosave\ Information/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Autosave Information/$bundle_id") + + # Contextual Menu Items + [[ -d ~/Library/Contextual\ Menu\ Items/"$app_name".plugin ]] && files_to_clean+=("$HOME/Library/Contextual Menu Items/$app_name.plugin") + + # Spotlight Plugins + [[ -d ~/Library/Spotlight/"$app_name".mdimporter ]] && files_to_clean+=("$HOME/Library/Spotlight/$app_name.mdimporter") + + # Color Pickers + [[ -d ~/Library/ColorPickers/"$app_name".colorPicker ]] && files_to_clean+=("$HOME/Library/ColorPickers/$app_name.colorPicker") + + # Workflows + [[ -d ~/Library/Workflows/"$app_name".workflow ]] && files_to_clean+=("$HOME/Library/Workflows/$app_name.workflow") + + # Unix-style configuration directories and files (cross-platform apps) + [[ -d ~/.config/"$app_name" ]] && files_to_clean+=("$HOME/.config/$app_name") + [[ -d ~/.local/share/"$app_name" ]] && files_to_clean+=("$HOME/.local/share/$app_name") + [[ -d ~/."$app_name" ]] && files_to_clean+=("$HOME/.$app_name") + [[ -f ~/."${app_name}rc" ]] && files_to_clean+=("$HOME/.${app_name}rc") + + # 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 +} + +# Find system-level app files (requires sudo) +find_app_system_files() { + local bundle_id="$1" + local app_name="$2" + local -a system_files=() + + # System Application Support + [[ -d /Library/Application\ Support/"$app_name" ]] && system_files+=("/Library/Application Support/$app_name") + [[ -d /Library/Application\ Support/"$bundle_id" ]] && system_files+=("/Library/Application Support/$bundle_id") + + # System Launch Agents + [[ -f /Library/LaunchAgents/"$bundle_id".plist ]] && system_files+=("/Library/LaunchAgents/$bundle_id.plist") + + # System Launch Daemons + [[ -f /Library/LaunchDaemons/"$bundle_id".plist ]] && system_files+=("/Library/LaunchDaemons/$bundle_id.plist") + + # Privileged Helper Tools + [[ -d /Library/PrivilegedHelperTools ]] && while IFS= read -r -d '' helper; do + system_files+=("$helper") + done < <(find /Library/PrivilegedHelperTools \( -name \"$bundle_id*\" \) -print0 2> /dev/null) + + # System Preferences + [[ -f /Library/Preferences/"$bundle_id".plist ]] && system_files+=("/Library/Preferences/$bundle_id.plist") + + # Installation Receipts + [[ -d /private/var/db/receipts ]] && while IFS= read -r -d '' receipt; do + system_files+=("$receipt") + done < <(find /private/var/db/receipts \( -name \"*$bundle_id*\" \) -print0 2> /dev/null) + + # System Logs + [[ -d /Library/Logs/"$app_name" ]] && system_files+=("/Library/Logs/$app_name") + [[ -d /Library/Logs/"$bundle_id" ]] && system_files+=("/Library/Logs/$bundle_id") + + # System Frameworks + [[ -d /Library/Frameworks/"$app_name".framework ]] && system_files+=("/Library/Frameworks/$app_name.framework") + + # System QuickLook Plugins + [[ -d /Library/QuickLook/"$app_name".qlgenerator ]] && system_files+=("/Library/QuickLook/$app_name.qlgenerator") + + # System Preference Panes + [[ -d /Library/PreferencePanes/"$app_name".prefPane ]] && system_files+=("/Library/PreferencePanes/$app_name.prefPane") + + # System Screen Savers + [[ -d /Library/Screen\ Savers/"$app_name".saver ]] && system_files+=("/Library/Screen Savers/$app_name.saver") + + # System Caches + [[ -d /Library/Caches/"$bundle_id" ]] && system_files+=("/Library/Caches/$bundle_id") + [[ -d /Library/Caches/"$app_name" ]] && system_files+=("/Library/Caches/$app_name") + + # Only print if array has elements + if [[ ${#system_files[@]} -gt 0 ]]; then + printf '%s\n' "${system_files[@]}" + fi +} + +# Force quit an application +force_kill_app() { + # Args: app_name [app_path]; tries graceful then force kill; returns 0 if stopped, 1 otherwise + local app_name="$1" + local app_path="${2:-""}" + + # Get the executable name from bundle if app_path is provided + local exec_name="" + if [[ -n "$app_path" && -e "$app_path/Contents/Info.plist" ]]; then + exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "") + fi + + # Use executable name for precise matching, fallback to app name + local match_pattern="${exec_name:-$app_name}" + + # Check if process is running using exact match only + if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then + return 0 + fi + + # Try graceful termination first + pkill -x "$match_pattern" 2> /dev/null || true + sleep 2 + + # Check again after graceful kill + if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then + return 0 + fi + + # Force kill if still running + pkill -9 -x "$match_pattern" 2> /dev/null || true + sleep 2 + + # If still running and sudo is available, try with sudo + if pgrep -x "$match_pattern" > /dev/null 2>&1; then + if sudo -n true 2> /dev/null; then + sudo pkill -9 -x "$match_pattern" 2> /dev/null || true + sleep 2 + fi + fi + + # Final check with longer timeout for stubborn processes + local retries=3 + while [[ $retries -gt 0 ]]; do + if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then + return 0 + fi + sleep 1 + ((retries--)) + done + + # Still running after all attempts + pgrep -x "$match_pattern" > /dev/null 2>&1 && return 1 || return 0 +} + +# 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 + size_kb=$(get_path_size_kb "$file") + ((total_kb += size_kb)) + fi + done <<< "$files" + + echo "$total_kb" +} diff --git a/lib/core/base.sh b/lib/core/base.sh new file mode 100644 index 0000000..7216b48 --- /dev/null +++ b/lib/core/base.sh @@ -0,0 +1,339 @@ +#!/bin/bash +# Mole - Base Definitions and Utilities +# Core definitions, constants, and basic utility functions used by all modules + +set -euo pipefail + +# Prevent multiple sourcing +if [[ -n "${MOLE_BASE_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_BASE_LOADED=1 + +# ============================================================================ +# Color Definitions +# ============================================================================ +readonly ESC=$'\033' +readonly GREEN="${ESC}[0;32m" +readonly BLUE="${ESC}[0;34m" +readonly CYAN="${ESC}[0;36m" +readonly YELLOW="${ESC}[0;33m" +readonly PURPLE="${ESC}[0;35m" +readonly PURPLE_BOLD="${ESC}[1;35m" +readonly RED="${ESC}[0;31m" +readonly GRAY="${ESC}[0;90m" +readonly NC="${ESC}[0m" + +# ============================================================================ +# Icon Definitions +# ============================================================================ +readonly ICON_CONFIRM="◎" +readonly ICON_ADMIN="⚙" +readonly ICON_SUCCESS="✓" +readonly ICON_ERROR="☻" +readonly ICON_EMPTY="○" +readonly ICON_SOLID="●" +readonly ICON_LIST="•" +readonly ICON_ARROW="➤" +readonly ICON_WARNING="☻" +readonly ICON_NAV_UP="↑" +readonly ICON_NAV_DOWN="↓" +readonly ICON_NAV_LEFT="←" +readonly ICON_NAV_RIGHT="→" + +# ============================================================================ +# Global Configuration Constants +# ============================================================================ +readonly MOLE_TEMP_FILE_AGE_DAYS=7 # Temp file cleanup threshold +readonly MOLE_ORPHAN_AGE_DAYS=60 # Orphaned data threshold +readonly MOLE_MAX_PARALLEL_JOBS=15 # Parallel job limit +readonly MOLE_MAIL_DOWNLOADS_MIN_KB=5120 # Mail attachments size threshold +readonly MOLE_LOG_AGE_DAYS=7 # System log retention +readonly MOLE_CRASH_REPORT_AGE_DAYS=7 # Crash report retention +readonly MOLE_SAVED_STATE_AGE_DAYS=7 # App saved state retention +readonly MOLE_TM_BACKUP_SAFE_HOURS=48 # Time Machine failed backup safety window + +# ============================================================================ +# Whitelist Configuration +# ============================================================================ +readonly FINDER_METADATA_SENTINEL="FINDER_METADATA" +declare -a DEFAULT_WHITELIST_PATTERNS=( + "$HOME/Library/Caches/ms-playwright*" + "$HOME/.cache/huggingface*" + "$HOME/.m2/repository/*" + "$HOME/.ollama/models/*" + "$HOME/Library/Caches/com.nssurge.surge-mac/*" + "$HOME/Library/Application Support/com.nssurge.surge-mac/*" + "$HOME/Library/Caches/org.R-project.R/R/renv/*" + "$FINDER_METADATA_SENTINEL" +) + +# ============================================================================ +# BSD Stat Compatibility +# ============================================================================ +readonly STAT_BSD="/usr/bin/stat" + +# Get file size in bytes using BSD stat +get_file_size() { + local file="$1" + local result + result=$($STAT_BSD -f%z "$file" 2> /dev/null) + echo "${result:-0}" +} + +# Get file modification time using BSD stat +# Returns: epoch seconds +get_file_mtime() { + local file="$1" + [[ -z "$file" ]] && { + echo "0" + return + } + local result + result=$($STAT_BSD -f%m "$file" 2> /dev/null) + echo "${result:-0}" +} + +# Get file owner username using BSD stat +get_file_owner() { + local file="$1" + $STAT_BSD -f%Su "$file" 2> /dev/null || echo "" +} + +# ============================================================================ +# System Utilities +# ============================================================================ + +# Check if System Integrity Protection is enabled +# Returns: 0 if SIP is enabled, 1 if disabled or cannot determine +is_sip_enabled() { + if ! command -v csrutil > /dev/null 2>&1; then + return 0 + fi + + local sip_status + sip_status=$(csrutil status 2> /dev/null || echo "") + + if echo "$sip_status" | grep -qi "enabled"; then + return 0 + else + return 1 + fi +} + +# Check if running in interactive terminal +# Returns: 0 if interactive, 1 otherwise +is_interactive() { + [[ -t 1 ]] +} + +# Detect CPU architecture +# Returns: "Apple Silicon" or "Intel" +detect_architecture() { + if [[ "$(uname -m)" == "arm64" ]]; then + echo "Apple Silicon" + else + echo "Intel" + fi +} + +# Get free disk space on root volume +# Returns: human-readable string (e.g., "100G") +get_free_space() { + command df -h / | awk 'NR==2 {print $4}' +} + +# Get optimal number of parallel jobs for a given operation type +# Args: $1 - operation type (scan|io|compute|default) +# Returns: number of jobs +get_optimal_parallel_jobs() { + local operation_type="${1:-default}" + local cpu_cores + cpu_cores=$(sysctl -n hw.ncpu 2> /dev/null || echo 4) + case "$operation_type" in + scan | io) + echo $((cpu_cores * 2)) + ;; + compute) + echo "$cpu_cores" + ;; + *) + echo $((cpu_cores + 2)) + ;; + esac +} + +# ============================================================================ +# Formatting Utilities +# ============================================================================ + +# Convert bytes to human-readable format +# Args: $1 - size in bytes +# Returns: formatted string (e.g., "1.50GB", "256MB", "4KB") +bytes_to_human() { + local bytes="$1" + if [[ ! "$bytes" =~ ^[0-9]+$ ]]; then + echo "0B" + return 1 + fi + + if ((bytes >= 1073741824)); then # >= 1GB + local divisor=1073741824 + local whole=$((bytes / divisor)) + local remainder=$((bytes % divisor)) + local frac=$(((remainder * 100 + divisor / 2) / divisor)) + if ((frac >= 100)); then + frac=0 + ((whole++)) + fi + printf "%d.%02dGB\n" "$whole" "$frac" + return 0 + fi + + if ((bytes >= 1048576)); then # >= 1MB + local divisor=1048576 + local whole=$((bytes / divisor)) + local remainder=$((bytes % divisor)) + local frac=$(((remainder * 10 + divisor / 2) / divisor)) + if ((frac >= 10)); then + frac=0 + ((whole++)) + fi + printf "%d.%01dMB\n" "$whole" "$frac" + return 0 + fi + + if ((bytes >= 1024)); then + local rounded_kb=$(((bytes + 512) / 1024)) + printf "%dKB\n" "$rounded_kb" + return 0 + fi + + printf "%dB\n" "$bytes" +} + +# Convert kilobytes to human-readable format +# Args: $1 - size in KB +# Returns: formatted string +bytes_to_human_kb() { + bytes_to_human "$((${1:-0} * 1024))" +} + +# Get brand-friendly name for an application +# Args: $1 - application name +# Returns: branded name if mapping exists, original name otherwise +get_brand_name() { + local name="$1" + + case "$name" in + "qiyimac" | "爱奇艺") echo "iQiyi" ;; + "wechat" | "微信") echo "WeChat" ;; + "QQ") echo "QQ" ;; + "VooV Meeting" | "腾讯会议") echo "VooV Meeting" ;; + "dingtalk" | "钉钉") echo "DingTalk" ;; + "NeteaseMusic" | "网易云音乐") echo "NetEase Music" ;; + "BaiduNetdisk" | "百度网盘") echo "Baidu NetDisk" ;; + "alipay" | "支付宝") echo "Alipay" ;; + "taobao" | "淘宝") echo "Taobao" ;; + "futunn" | "富途牛牛") echo "Futu NiuNiu" ;; + "tencent lemon" | "Tencent Lemon Cleaner") echo "Tencent Lemon" ;; + "keynote" | "Keynote") echo "Keynote" ;; + "pages" | "Pages") echo "Pages" ;; + "numbers" | "Numbers") echo "Numbers" ;; + *) echo "$name" ;; + esac +} + +# ============================================================================ +# Temporary File Management +# ============================================================================ + +# Tracked temporary files and directories +declare -a MOLE_TEMP_FILES=() +declare -a MOLE_TEMP_DIRS=() + +# Create tracked temporary file +# Returns: temp file path +create_temp_file() { + local temp + temp=$(mktemp) || return 1 + MOLE_TEMP_FILES+=("$temp") + echo "$temp" +} + +# Create tracked temporary directory +# Returns: temp directory path +create_temp_dir() { + local temp + temp=$(mktemp -d) || return 1 + MOLE_TEMP_DIRS+=("$temp") + echo "$temp" +} + +# Register existing file for cleanup +register_temp_file() { + MOLE_TEMP_FILES+=("$1") +} + +# Register existing directory for cleanup +register_temp_dir() { + MOLE_TEMP_DIRS+=("$1") +} + +# Create temp file with prefix (for analyze.sh compatibility) +mktemp_file() { + local prefix="${1:-mole}" + mktemp -t "$prefix" +} + +# Cleanup all tracked temp files and directories +cleanup_temp_files() { + local file + if [[ ${#MOLE_TEMP_FILES[@]} -gt 0 ]]; then + for file in "${MOLE_TEMP_FILES[@]}"; do + [[ -f "$file" ]] && rm -f "$file" 2> /dev/null || true + done + fi + + if [[ ${#MOLE_TEMP_DIRS[@]} -gt 0 ]]; then + for file in "${MOLE_TEMP_DIRS[@]}"; do + [[ -d "$file" ]] && rm -rf "$file" 2> /dev/null || true + done + fi + + MOLE_TEMP_FILES=() + MOLE_TEMP_DIRS=() +} + +# ============================================================================ +# Section Tracking (for progress indication) +# ============================================================================ + +# Global section tracking variables +TRACK_SECTION=0 +SECTION_ACTIVITY=0 + +# Start a new section +# Args: $1 - section title +start_section() { + TRACK_SECTION=1 + SECTION_ACTIVITY=0 + echo "" + echo -e "${PURPLE_BOLD}${ICON_ARROW} $1${NC}" +} + +# End a section +# Shows "Nothing to tidy" if no activity was recorded +end_section() { + if [[ $TRACK_SECTION -eq 1 && $SECTION_ACTIVITY -eq 0 ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Nothing to tidy" + fi + TRACK_SECTION=0 +} + +# Mark activity in current section +note_activity() { + if [[ $TRACK_SECTION -eq 1 ]]; then + SECTION_ACTIVITY=1 + fi +} diff --git a/lib/core/common.sh b/lib/core/common.sh index c8bedf0..9faf180 100755 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -1,6 +1,6 @@ #!/bin/bash # Mole - Common Functions Library -# Shared utilities and functions for all modules +# Main entry point that loads all core modules set -euo pipefail @@ -10,958 +10,40 @@ if [[ -n "${MOLE_COMMON_LOADED:-}" ]]; then fi readonly MOLE_COMMON_LOADED=1 -# Color definitions (readonly for safety) -readonly ESC=$'\033' -readonly GREEN="${ESC}[0;32m" -readonly BLUE="${ESC}[0;34m" -readonly CYAN="${ESC}[0;36m" -readonly YELLOW="${ESC}[0;33m" -readonly PURPLE="${ESC}[0;35m" -readonly PURPLE_BOLD="${ESC}[1;35m" -readonly RED="${ESC}[0;31m" -readonly GRAY="${ESC}[0;90m" -readonly NC="${ESC}[0m" - -# Icon definitions -readonly ICON_CONFIRM="◎" -readonly ICON_ADMIN="⚙" -readonly ICON_SUCCESS="✓" -readonly ICON_ERROR="☻" -readonly ICON_EMPTY="○" -readonly ICON_SOLID="●" -readonly ICON_LIST="•" -readonly ICON_ARROW="➤" -readonly ICON_WARNING="☻" -readonly ICON_NAV_UP="↑" -readonly ICON_NAV_DOWN="↓" -readonly ICON_NAV_LEFT="←" -readonly ICON_NAV_RIGHT="→" - -# Global configuration constants -readonly MOLE_TEMP_FILE_AGE_DAYS=7 # Temp file cleanup threshold -readonly MOLE_ORPHAN_AGE_DAYS=60 # Orphaned data threshold -readonly MOLE_MAX_PARALLEL_JOBS=15 # Parallel job limit -readonly MOLE_MAIL_DOWNLOADS_MIN_KB=5120 # Mail attachments size threshold (~5MB) -readonly MOLE_LOG_AGE_DAYS=7 # System log retention -readonly MOLE_CRASH_REPORT_AGE_DAYS=7 # Crash report retention -readonly MOLE_SAVED_STATE_AGE_DAYS=7 # App saved state retention -readonly MOLE_TM_BACKUP_SAFE_HOURS=48 # Time Machine failed backup safety window - -# Whitelist configuration -readonly FINDER_METADATA_SENTINEL="FINDER_METADATA" -declare -a DEFAULT_WHITELIST_PATTERNS=( - "$HOME/Library/Caches/ms-playwright*" - "$HOME/.cache/huggingface*" - "$HOME/.m2/repository/*" - "$HOME/.ollama/models/*" - "$HOME/Library/Caches/com.nssurge.surge-mac/*" - "$HOME/Library/Application Support/com.nssurge.surge-mac/*" - "$HOME/Library/Caches/org.R-project.R/R/renv/*" - "$FINDER_METADATA_SENTINEL" -) - -# Check if System Integrity Protection is enabled -# Returns: 0 if SIP is enabled, 1 if disabled or cannot determine -is_sip_enabled() { - if ! command -v csrutil > /dev/null 2>&1; then - # If csrutil not available, assume SIP is enabled for safety - return 0 - fi - - local sip_status - sip_status=$(csrutil status 2> /dev/null || echo "") - - if echo "$sip_status" | grep -qi "enabled"; then - return 0 - else - return 1 - fi -} - -# Check if running in interactive terminal -# Returns: 0 if interactive (stdout is a terminal), 1 otherwise -# Usage: if is_interactive; then echo "Interactive mode"; fi -is_interactive() { - [[ -t 1 ]] -} - -# Get spinner characters (overridable via MO_SPINNER_CHARS) -mo_spinner_chars() { - local chars="${MO_SPINNER_CHARS:-|/-\\}" - [[ -z "$chars" ]] && chars="|/-\\" - printf "%s" "$chars" -} - -# BSD stat compatibility -readonly STAT_BSD="/usr/bin/stat" - -# Get file size in bytes using BSD stat -get_file_size() { - local file="$1" - local result - result=$($STAT_BSD -f%z "$file" 2> /dev/null) - echo "${result:-0}" -} - -# Get file modification time (epoch seconds) using BSD stat -get_file_mtime() { - local file="$1" - [[ -z "$file" ]] && { - echo "0" - return - } - local result - result=$($STAT_BSD -f%m "$file" 2> /dev/null) - echo "${result:-0}" -} - -# Get file owner username using BSD stat -get_file_owner() { - local file="$1" - $STAT_BSD -f%Su "$file" 2> /dev/null || echo "" -} - -# Security and Path Validation Functions - -# Validates path for deletion (absolute, no control chars, not system dir) -validate_path_for_deletion() { - local path="$1" - - # Check path is not empty - if [[ -z "$path" ]]; then - log_error "Path validation failed: empty path" - return 1 - fi - - # Check path is absolute - if [[ "$path" != /* ]]; then - log_error "Path validation failed: path must be absolute: $path" - return 1 - fi - - # Check for path traversal attempts - if [[ "$path" =~ \.\. ]]; then - log_error "Path validation failed: path traversal not allowed: $path" - return 1 - fi - - # Check path doesn't contain dangerous characters - if [[ "$path" =~ [[:cntrl:]] ]] || [[ "$path" =~ $'\n' ]]; then - log_error "Path validation failed: contains control characters: $path" - return 1 - fi - - # Check path isn't critical system directory - case "$path" in - / | /bin | /sbin | /usr | /usr/bin | /usr/sbin | /etc | /var | /System | /System/* | /Library/Extensions) - log_error "Path validation failed: critical system directory: $path" - return 1 - ;; - esac - - # Path is safe - return 0 -} - -# Safe wrapper around rm -rf with path validation and logging -# Usage: safe_remove "/path" [silent] -safe_remove() { - local path="$1" - local silent="${2:-false}" - - # Validate path - if ! validate_path_for_deletion "$path"; then - return 1 - fi - - # Check if path exists - if [[ ! -e "$path" ]]; then - return 0 - fi - - debug_log "Removing: $path" - - # Perform the deletion (log only on error) - if rm -rf "$path" 2> /dev/null; then - return 0 - else - [[ "$silent" != "true" ]] && log_error "Failed to remove: $path" - return 1 - fi -} - -# Safe sudo remove with validation (rejects symlinks) -# Usage: safe_sudo_remove "/path" -safe_sudo_remove() { - local path="$1" - - # Validate path - if ! validate_path_for_deletion "$path"; then - log_error "Path validation failed for sudo remove: $path" - return 1 - fi - - # Check if path exists - if [[ ! -e "$path" ]]; then - return 0 - fi - - # Additional check: reject symlinks for sudo operations - if [[ -L "$path" ]]; then - log_error "Refusing to sudo remove symlink: $path" - return 1 - fi - - debug_log "Removing (sudo): $path" - - # Perform the deletion (log only on error) - if sudo rm -rf "$path" 2> /dev/null; then - return 0 - else - log_error "Failed to remove (sudo): $path" - return 1 - fi -} - -# Safe find delete with depth limit and validation -# Usage: safe_find_delete "/dir" "pattern" age_days "f|d" -safe_find_delete() { - local base_dir="$1" - local pattern="$2" - local age_days="${3:-7}" - local type_filter="${4:-f}" - - # Validate base directory exists and is not a symlink - if [[ ! -d "$base_dir" ]]; then - log_error "Directory does not exist: $base_dir" - return 1 - fi - - if [[ -L "$base_dir" ]]; then - log_error "Refusing to search symlinked directory: $base_dir" - return 1 - fi - - # Validate type filter - if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then - log_error "Invalid type filter: $type_filter (must be 'f' or 'd')" - return 1 - fi - - # Execute find with safety limits (maxdepth 5 covers most app cache structures) - debug_log "Finding in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)" - - # When age_days is 0, delete all matching files without time restriction - if [[ "$age_days" -eq 0 ]]; then - command find "$base_dir" \ - -maxdepth 5 \ - -name "$pattern" \ - -type "$type_filter" \ - -delete 2> /dev/null || true - else - command find "$base_dir" \ - -maxdepth 5 \ - -name "$pattern" \ - -type "$type_filter" \ - -mtime "+$age_days" \ - -delete 2> /dev/null || true - fi - - return 0 -} - -# Safe sudo find delete (same as safe_find_delete with sudo) -# Usage: safe_sudo_find_delete "/dir" "pattern" age_days "f|d" -safe_sudo_find_delete() { - local base_dir="$1" - local pattern="$2" - local age_days="${3:-7}" - local type_filter="${4:-f}" - - # Validate base directory exists and is not a symlink - if [[ ! -d "$base_dir" ]]; then - log_error "Directory does not exist: $base_dir" - return 1 - fi - - if [[ -L "$base_dir" ]]; then - log_error "Refusing to search symlinked directory: $base_dir" - return 1 - fi - - # Validate type filter - if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then - log_error "Invalid type filter: $type_filter (must be 'f' or 'd')" - return 1 - fi - - # Execute find with safety limits (maxdepth 5 covers most app cache structures) - debug_log "Finding (sudo) in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)" - - # When age_days is 0, delete all matching files without time restriction - if [[ "$age_days" -eq 0 ]]; then - sudo command find "$base_dir" \ - -maxdepth 5 \ - -name "$pattern" \ - -type "$type_filter" \ - -delete 2> /dev/null || true - else - sudo command find "$base_dir" \ - -maxdepth 5 \ - -name "$pattern" \ - -type "$type_filter" \ - -mtime "+$age_days" \ - -delete 2> /dev/null || true - fi - - return 0 -} - -# Logging configuration -readonly LOG_FILE="${HOME}/.config/mole/mole.log" -readonly LOG_MAX_SIZE_DEFAULT=1048576 # 1MB - -# Ensure log directory exists -mkdir -p "$(dirname "$LOG_FILE")" 2> /dev/null || true - -# Log rotation check (called once at startup, not per log entry) -rotate_log_once() { - # Skip if already checked this session - [[ -n "${MOLE_LOG_ROTATED:-}" ]] && return 0 - export MOLE_LOG_ROTATED=1 - - local max_size="${MOLE_MAX_LOG_SIZE:-$LOG_MAX_SIZE_DEFAULT}" - if [[ -f "$LOG_FILE" ]] && [[ $(get_file_size "$LOG_FILE") -gt "$max_size" ]]; then - mv "$LOG_FILE" "${LOG_FILE}.old" 2> /dev/null || true - touch "$LOG_FILE" 2> /dev/null || true - fi -} - -# Simplified logging functions (no per-call rotation check) -log_info() { - echo -e "${BLUE}$1${NC}" - echo "[$(date '+%Y-%m-%d %H:%M:%S')] INFO: $1" >> "$LOG_FILE" 2> /dev/null || true -} - -log_success() { - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1" - echo "[$(date '+%Y-%m-%d %H:%M:%S')] SUCCESS: $1" >> "$LOG_FILE" 2> /dev/null || true -} - -log_warning() { - echo -e "${YELLOW}$1${NC}" - echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARNING: $1" >> "$LOG_FILE" 2> /dev/null || true -} - -log_error() { - echo -e "${RED}${ICON_ERROR}${NC} $1" >&2 - echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true -} - -# Debug logging - shown when MO_DEBUG=1 -debug_log() { - if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo -e "${GRAY}[DEBUG]${NC} $*" >&2 - fi -} - -# Run command with optional error handling -# Usage: run_silent command args... # Ignore errors -# run_logged command args... # Log errors but continue -run_silent() { - "$@" > /dev/null 2>&1 || true -} - -run_logged() { - local cmd="$1" - if ! "$@" 2>&1 | tee -a "$LOG_FILE" > /dev/null; then - log_warning "Command failed: $cmd" - return 1 - fi - return 0 -} - -# Call rotation check once when common.sh is sourced -rotate_log_once - -# Print formatted summary block with heading and details -# Args: $1=status (ignored), $2=heading, $@=details -print_summary_block() { - local heading="" - - if [[ $# -gt 0 ]]; then - shift - fi - - if [[ $# -gt 0 ]]; then - heading="$1" - shift - fi - - local -a details=("$@") - local divider="======================================================================" - - echo "$divider" - if [[ -n "$heading" ]]; then - echo -e "${BLUE}${heading}${NC}" - fi - for detail in "${details[@]}"; do - [[ -z "$detail" ]] && continue - echo -e "${detail}" - done - echo "$divider" -} - -# Detect CPU architecture (Apple Silicon or Intel) -detect_architecture() { - if [[ "$(uname -m)" == "arm64" ]]; then - echo "Apple Silicon" - else - echo "Intel" - fi -} - -# Get free disk space on root volume (human-readable) -get_free_space() { - command df -h / | awk 'NR==2 {print $4}' -} - -# Clear terminal screen and move cursor to home -clear_screen() { - printf '\033[2J\033[H' -} - -# Hide terminal cursor -hide_cursor() { - [[ -t 1 ]] || return 0 - # Output to stderr for consistency with spinner, ensure unbuffered - printf '\033[?25l' >&2 -} - -# Show terminal cursor -show_cursor() { - [[ -t 1 ]] || return 0 - # Output to stderr for consistency with spinner, ensure unbuffered - printf '\033[?25h' >&2 -} - -# Read single keypress and return normalized key name -# Returns: ENTER, SPACE, UP, DOWN, LEFT, RIGHT, QUIT, DELETE, CHAR: -read_key() { - local key rest read_status - - # Read with explicit status check - IFS= read -r -s -n 1 key - read_status=$? - - # Handle read failure (Ctrl+D, EOF, etc.) - treat as quit - if [[ $read_status -ne 0 ]]; then - echo "QUIT" - return 0 - fi - - # Raw typing mode (filter): map most keys to CHAR: - if [[ "${MOLE_READ_KEY_FORCE_CHAR:-}" == "1" ]]; then - if [[ -z "$key" ]]; then - echo "ENTER" - return 0 - fi - case "$key" in - $'\n' | $'\r') echo "ENTER" ;; - $'\x7f' | $'\x08') echo "DELETE" ;; - $'\x1b') echo "QUIT" ;; # ESC cancels filter - [[:print:]]) echo "CHAR:$key" ;; - *) echo "OTHER" ;; - esac - return 0 - fi - - # Empty key = Enter - if [[ -z "$key" ]]; then - echo "ENTER" - return 0 - fi - - case "$key" in - $'\n' | $'\r') echo "ENTER" ;; - ' ') echo "SPACE" ;; - 'q' | 'Q') echo "QUIT" ;; - 'h') echo "LEFT" ;; - 'j') echo "DOWN" ;; - 'k') echo "UP" ;; - 'l') echo "RIGHT" ;; - 'm' | 'M') echo "MORE" ;; - 'v' | 'V') echo "VERSION" ;; - 't' | 'T') echo "TOUCHID" ;; - 'u' | 'U') echo "UPDATE" ;; - 'R') echo "RETRY" ;; - 'o' | 'O') echo "OPEN" ;; - '/') echo "FILTER" ;; - $'\x03') echo "QUIT" ;; # Ctrl+C - $'\x7f' | $'\x08') echo "DELETE" ;; - $'\x1b') - # ESC sequence - could be arrow key, delete key, or ESC alone - if IFS= read -r -s -n 1 -t 1 rest 2> /dev/null; then - if [[ "$rest" == "[" ]]; then - # ESC [ sequence - if IFS= read -r -s -n 1 -t 1 rest2 2> /dev/null; then - case "$rest2" in - "A") echo "UP" ;; - "B") echo "DOWN" ;; - "C") echo "RIGHT" ;; - "D") echo "LEFT" ;; - "3") - # Delete key: ESC [ 3 ~ - IFS= read -r -s -n 1 -t 1 rest3 2> /dev/null - [[ "$rest3" == "~" ]] && echo "DELETE" || echo "OTHER" - ;; - *) echo "OTHER" ;; - esac - else - echo "QUIT" - fi - elif [[ "$rest" == "O" ]]; then - # ESC O sequence (application keypad mode) - if IFS= read -r -s -n 1 -t 1 rest2 2> /dev/null; then - case "$rest2" 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 - # ESC alone - echo "QUIT" - fi - ;; - [[:print:]]) echo "CHAR:$key" ;; - *) echo "OTHER" ;; - esac -} - -# Drain pending keyboard/mouse input to prevent accidental triggers -drain_pending_input() { - local drained=0 - # Single pass with 0.01s timeout is sufficient for mouse wheel events - while IFS= read -r -s -n 1 -t 0.01 _ 2> /dev/null; do - ((drained++)) - [[ $drained -gt 100 ]] && break - done -} - -# Initialize timeout command (gtimeout or timeout) -if [[ -z "${MO_TIMEOUT_INITIALIZED:-}" ]]; then - MO_TIMEOUT_BIN="" - for candidate in gtimeout timeout; do - if command -v "$candidate" > /dev/null 2>&1; then - MO_TIMEOUT_BIN="$candidate" - break - fi - done - export MO_TIMEOUT_INITIALIZED=1 +_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Load core modules in dependency order +source "$_MOLE_CORE_DIR/base.sh" +source "$_MOLE_CORE_DIR/log.sh" +source "$_MOLE_CORE_DIR/timeout.sh" +source "$_MOLE_CORE_DIR/file_ops.sh" +source "$_MOLE_CORE_DIR/ui.sh" +source "$_MOLE_CORE_DIR/app_protection.sh" + +# Load sudo management if available +if [[ -f "$_MOLE_CORE_DIR/sudo.sh" ]]; then + source "$_MOLE_CORE_DIR/sudo.sh" fi -# Run command with timeout (uses gtimeout/timeout if available, fallback to kill) -# Args: $1=seconds, $@=command -run_with_timeout() { - local duration="${1:-0}" - shift || true - - if [[ ! "$duration" =~ ^[0-9]+$ ]] || [[ "$duration" -le 0 ]]; then - "$@" - return $? - fi - - if [[ -n "${MO_TIMEOUT_BIN:-}" ]]; then - "$MO_TIMEOUT_BIN" "$duration" "$@" - return $? - fi - - "$@" & - local cmd_pid=$! - - ( - sleep "$duration" - kill -TERM "$cmd_pid" 2> /dev/null || true - ) & - local killer_pid=$! - - local exit_code - set +e - wait "$cmd_pid" 2> /dev/null - exit_code=$? - set -e - - kill "$killer_pid" 2> /dev/null || true - wait "$killer_pid" 2> /dev/null || true - - return "$exit_code" -} - -# Menu display helper -show_menu_option() { - local number="$1" - local text="$2" - local selected="$3" - - if [[ "$selected" == "true" ]]; then - echo -e "${CYAN}${ICON_ARROW} $number. $text${NC}" - else - echo " $number. $text" - fi -} - -# Error handling -# File size utilities -# Convert bytes to human readable format -bytes_to_human() { - local bytes="$1" - if [[ ! "$bytes" =~ ^[0-9]+$ ]]; then - echo "0B" - return 1 - fi - - if ((bytes >= 1073741824)); then # >= 1GB - local divisor=1073741824 - local whole=$((bytes / divisor)) - local remainder=$((bytes % divisor)) - local frac=$(((remainder * 100 + divisor / 2) / divisor)) # Two decimals, rounded - if ((frac >= 100)); then - frac=0 - ((whole++)) - fi - printf "%d.%02dGB\n" "$whole" "$frac" - return 0 - fi - - if ((bytes >= 1048576)); then # >= 1MB - local divisor=1048576 - local whole=$((bytes / divisor)) - local remainder=$((bytes % divisor)) - local frac=$(((remainder * 10 + divisor / 2) / divisor)) # One decimal, rounded - if ((frac >= 10)); then - frac=0 - ((whole++)) - fi - printf "%d.%01dMB\n" "$whole" "$frac" - return 0 - fi - - if ((bytes >= 1024)); then # >= 1KB - local rounded_kb=$(((bytes + 512) / 1024)) # Nearest integer KB - printf "%dKB\n" "$rounded_kb" - return 0 - fi - - printf "%dB\n" "$bytes" -} - -# Calculate directory size in bytes -# List login items (one per line) -list_login_items() { - if ! command -v osascript > /dev/null 2>&1; then - return - fi - - local raw_items - raw_items=$(osascript -e 'tell application "System Events" to get the name of every login item' 2> /dev/null || echo "") - [[ -z "$raw_items" || "$raw_items" == "missing value" ]] && return - - IFS=',' read -ra login_items_array <<< "$raw_items" - for entry in "${login_items_array[@]}"; do - local trimmed - trimmed=$(echo "$entry" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') - [[ -n "$trimmed" ]] && printf "%s\n" "$trimmed" - done -} - -# Permission checks -# Check if Touch ID is configured for sudo -check_touchid_support() { - if [[ -f /etc/pam.d/sudo ]]; then - grep -q "pam_tid.so" /etc/pam.d/sudo 2> /dev/null - return $? - fi - return 1 -} - -# Check if Mac is in clamshell mode (lid closed with external display) -is_clamshell_mode() { - # ioreg is missing (not macOS) -> treat as lid open - if ! command -v ioreg > /dev/null 2>&1; then - return 1 - fi - - # Check if lid is closed; ignore pipeline failures so set -e doesn't exit - local clamshell_state="" - clamshell_state=$( (ioreg -r -k AppleClamshellState -d 4 2> /dev/null | - grep "AppleClamshellState" | - head -1) || true) - - if [[ "$clamshell_state" =~ \"AppleClamshellState\"\ =\ Yes ]]; then - return 0 # Lid is closed - fi - return 1 # Lid is open -} - -# Manual password input (no Touch ID) -_request_password() { - local tty_path="$1" - local attempts=0 - local show_hint=true - - # Extra safety: ensure sudo cache is cleared before password input - sudo -k 2> /dev/null - - while ((attempts < 3)); do - local password="" - - # Show hint on first attempt about Touch ID appearing again - if [[ $show_hint == true ]] && check_touchid_support; then - echo -e "${GRAY}Note: Touch ID dialog may appear once more - just cancel it${NC}" > "$tty_path" - show_hint=false - fi - - printf "${PURPLE}${ICON_ARROW}${NC} Password: " > "$tty_path" - IFS= read -r -s password < "$tty_path" || password="" - printf "\n" > "$tty_path" - - if [[ -z "$password" ]]; then - unset password - ((attempts++)) - if [[ $attempts -lt 3 ]]; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Password cannot be empty" > "$tty_path" - fi - continue - fi - - # Verify password with sudo - # NOTE: macOS PAM will trigger Touch ID before password auth - this is system behavior - if printf '%s\n' "$password" | sudo -S -p "" -v > /dev/null 2>&1; then - unset password - return 0 - fi - - unset password - ((attempts++)) - if [[ $attempts -lt 3 ]]; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Incorrect password, try again" > "$tty_path" - fi - done - - return 1 -} - -# Request sudo access with Touch ID support -# Usage: request_sudo_access "prompt message" -request_sudo_access() { - local prompt_msg="${1:-Admin access required}" - - # Check if already have sudo access - if sudo -n true 2> /dev/null; then - return 0 - fi - - # Get TTY path - local tty_path="/dev/tty" - if [[ ! -r "$tty_path" || ! -w "$tty_path" ]]; then - tty_path=$(tty 2> /dev/null || echo "") - if [[ -z "$tty_path" || ! -r "$tty_path" || ! -w "$tty_path" ]]; then - log_error "No interactive terminal available" - return 1 - fi - fi - - sudo -k - - # Check if in clamshell mode - if yes, skip Touch ID entirely - if is_clamshell_mode; then - echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg}" - _request_password "$tty_path" - return $? - fi - - # Not in clamshell mode - try Touch ID if configured - if ! check_touchid_support; then - echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg}" - _request_password "$tty_path" - return $? - fi - - # Touch ID is available and not in clamshell mode - echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg} ${GRAY}(Touch ID or password)${NC}" - - # Start sudo in background so we can monitor and control it - sudo -v < /dev/null > /dev/null 2>&1 & - local sudo_pid=$! - - # Wait for sudo to complete or timeout (5 seconds) - local elapsed=0 - local timeout=50 # 50 * 0.1s = 5 seconds - while ((elapsed < timeout)); do - if ! kill -0 "$sudo_pid" 2> /dev/null; then - # Process exited - wait "$sudo_pid" 2> /dev/null - local exit_code=$? - if [[ $exit_code -eq 0 ]] && sudo -n true 2> /dev/null; then - # Touch ID succeeded - return 0 - fi - # Touch ID failed or cancelled - break - fi - sleep 0.1 - ((elapsed++)) - done - - # Touch ID failed/cancelled - clean up thoroughly before password input - - # Kill the sudo process if still running - if kill -0 "$sudo_pid" 2> /dev/null; then - kill -9 "$sudo_pid" 2> /dev/null - wait "$sudo_pid" 2> /dev/null || true - fi - - # Clear sudo state immediately - sudo -k 2> /dev/null - - # IMPORTANT: Wait longer for macOS to fully close Touch ID UI and SecurityAgent - # Without this delay, subsequent sudo calls may re-trigger Touch ID - sleep 1 - - # Clear any leftover prompts on the screen - printf "\r\033[2K" > "$tty_path" - - # Now use our password input (this should not trigger Touch ID again) - _request_password "$tty_path" - return $? -} - -# Legacy sudo request (no Touch ID, password only) -request_sudo() { - echo "This operation requires administrator privileges." - echo -n "Please enter your password: " - read -r -s password - echo - if echo "$password" | sudo -S true 2> /dev/null; then - return 0 - else - log_error "Invalid password or cancelled" - return 1 - fi -} - -# Update Mole via Homebrew with timeout and error handling -# Args: $1=current_version, Env: MO_BREW_UPDATE_TIMEOUT +# Update Mole via Homebrew +# Args: $1 = current version update_via_homebrew() { - local version="${1:-unknown}" - - # Set up cleanup trap to kill background process on interruption - local brew_pid="" - local brew_tmp_file="" - local brew_exit_file="" - # shellcheck disable=SC2329 - cleanup_brew_update() { - if [[ -n "$brew_pid" ]] && kill -0 "$brew_pid" 2> /dev/null; then - kill -TERM "$brew_pid" 2> /dev/null || true - wait "$brew_pid" 2> /dev/null || true - fi - [[ -n "$brew_tmp_file" ]] && rm -f "$brew_tmp_file" - [[ -n "$brew_exit_file" ]] && rm -f "$brew_exit_file" - [[ -t 1 ]] && stop_inline_spinner - } - trap cleanup_brew_update INT TERM + local current_version="$1" if [[ -t 1 ]]; then start_inline_spinner "Updating Homebrew..." else - echo "Updating Homebrew..." >&2 + echo "Updating Homebrew..." fi - - # Run brew update with timeout to prevent hanging - # Use background process to allow interruption - local brew_update_timeout="${MO_BREW_UPDATE_TIMEOUT:-300}" - brew_tmp_file=$(mktemp -t mole-brew-update 2> /dev/null || echo "/tmp/mole-brew-update.$$") - - # Redirect brew output to temp file to avoid interfering with spinner - # Store exit code in a separate file to avoid wait issues with zsh - brew_exit_file="${brew_tmp_file}.exit" - ( - brew update > "$brew_tmp_file" 2>&1 < /dev/null - echo $? > "$brew_exit_file" - ) & - brew_pid=$! - local elapsed=0 - - # Wait for completion or timeout - while kill -0 $brew_pid 2> /dev/null; do - if [[ $elapsed -ge $brew_update_timeout ]]; then - kill -TERM $brew_pid 2> /dev/null || true - sleep 0.5 - if [[ -t 1 ]]; then stop_inline_spinner; fi - rm -f "$brew_tmp_file" "$brew_exit_file" - trap - INT TERM - log_error "Homebrew update timed out (${brew_update_timeout}s)" - return 1 - fi - sleep 1 - ((elapsed++)) - done - - # Give the subshell a moment to write exit code - sleep 0.1 - - trap - INT TERM - - # Get brew update exit code from file instead of wait - local brew_exit=0 - if [[ -f "$brew_exit_file" ]]; then - brew_exit=$(cat "$brew_exit_file" 2> /dev/null || echo "0") - fi - rm -f "$brew_exit_file" - + brew update 2>&1 | grep -Ev "^(==>|Already up-to-date)" || true if [[ -t 1 ]]; then stop_inline_spinner fi - # Check if update failed with a real error (not just "already up-to-date") - local brew_update_success=true - local update_output="" - - if [[ -f "$brew_tmp_file" ]]; then - update_output=$(cat "$brew_tmp_file" 2> /dev/null) - fi - - # Check for errors in output (brew update may return 0 even on failure) - if [[ -n "$update_output" ]] && echo "$update_output" | grep -qiE "(^Error:|fatal:)"; then - brew_update_success=false - fi - - if [[ $brew_exit -ne 0 ]]; then - brew_update_success=false - fi - - # Show appropriate message - if [[ "$brew_update_success" == "false" ]]; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Homebrew update skipped (check network or proxy)" >&2 - else - # Only show success if there were no errors - echo -e "${GREEN}${ICON_SUCCESS}${NC} Homebrew formulae updated" >&2 - fi - - rm -f "$brew_tmp_file" - if [[ -t 1 ]]; then start_inline_spinner "Upgrading Mole..." else - echo "Upgrading Mole..." >&2 + echo "Upgrading Mole..." fi local upgrade_output upgrade_output=$(brew upgrade mole 2>&1) || true @@ -970,1285 +52,24 @@ update_via_homebrew() { fi if echo "$upgrade_output" | grep -q "already installed"; then - # Get current version - local current_version - current_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') - echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version (${current_version:-$version})" >&2 + local installed_version + installed_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') + echo "" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version (${installed_version:-$current_version})" + echo "" elif echo "$upgrade_output" | grep -q "Error:"; then log_error "Homebrew upgrade failed" echo "$upgrade_output" | grep "Error:" >&2 return 1 else - # Show relevant output, filter noise - echo "$upgrade_output" | grep -Ev "^(==>|Updating Homebrew|Warning:)" >&2 || true - # Get new version + echo "$upgrade_output" | grep -Ev "^(==>|Updating Homebrew|Warning:)" || true local new_version new_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') - echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-$version})" >&2 + echo "" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-$current_version})" + echo "" fi - # Clear version check cache - rm -f "$HOME/.cache/mole/update_message" - return 0 -} - -# Load basic configuration -load_config() { - MOLE_MAX_LOG_SIZE="${MOLE_MAX_LOG_SIZE:-1048576}" -} - -# Initialize configuration on sourcing -load_config - -# ============================================================================ -# Spinner and Progress Indicators -# ============================================================================ - -# Global spinner process ID -INLINE_SPINNER_PID="" - -# Start an inline spinner (rotating character) -start_inline_spinner() { - stop_inline_spinner 2> /dev/null || true - local message="$1" - - if [[ -t 1 ]]; then - ( - trap 'exit 0' TERM INT EXIT - local chars - chars="$(mo_spinner_chars)" - [[ -z "$chars" ]] && chars="|/-\\" - local i=0 - while true; do - local c="${chars:$((i % ${#chars})):1}" - # Output to stderr to avoid interfering with stdout - printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$message" >&2 || exit 0 - ((i++)) - # macOS supports decimal sleep, this is the primary target - sleep 0.1 2> /dev/null || sleep 1 2> /dev/null || exit 0 - done - ) & - INLINE_SPINNER_PID=$! - disown 2> /dev/null || true - else - echo -n " ${BLUE}|${NC} $message" >&2 - fi -} - -# Stop inline spinner -stop_inline_spinner() { - if [[ -n "$INLINE_SPINNER_PID" ]]; then - # Try graceful TERM first, then force KILL if needed - if kill -0 "$INLINE_SPINNER_PID" 2> /dev/null; then - kill -TERM "$INLINE_SPINNER_PID" 2> /dev/null || true - sleep 0.05 2> /dev/null || true - # Force kill if still running - kill -KILL "$INLINE_SPINNER_PID" 2> /dev/null || true - fi - wait "$INLINE_SPINNER_PID" 2> /dev/null || true - INLINE_SPINNER_PID="" - # Clear the line - use \033[2K to clear entire line, not just to end - [[ -t 1 ]] && printf "\r\033[2K" >&2 - fi -} - -# ============================================================================ -# User Interaction - Confirmation Dialogs -# ============================================================================ - -# ============================================================================ -# Temporary File Management -# ============================================================================ - -# Global temp file tracking -declare -a MOLE_TEMP_FILES=() -declare -a MOLE_TEMP_DIRS=() - -# Create tracked temporary file -# Returns: temp file path -create_temp_file() { - local temp - temp=$(mktemp) || return 1 - MOLE_TEMP_FILES+=("$temp") - echo "$temp" -} - -# Create tracked temporary directory -# Returns: temp directory path -create_temp_dir() { - local temp - temp=$(mktemp -d) || return 1 - MOLE_TEMP_DIRS+=("$temp") - echo "$temp" -} - -# Create temp file with prefix (for analyze.sh compatibility) -# Cleanup all tracked temp files -cleanup_temp_files() { - local file - if [[ ${#MOLE_TEMP_FILES[@]} -gt 0 ]]; then - for file in "${MOLE_TEMP_FILES[@]}"; do - [[ -f "$file" ]] && rm -f "$file" 2> /dev/null || true - done - fi - - if [[ ${#MOLE_TEMP_DIRS[@]} -gt 0 ]]; then - for file in "${MOLE_TEMP_DIRS[@]}"; do - [[ -d "$file" ]] && rm -rf "$file" 2> /dev/null || true - done - fi - - MOLE_TEMP_FILES=() - MOLE_TEMP_DIRS=() -} - -# Auto-cleanup on script exit (call this in main scripts) -# ============================================================================ -# Lightweight spinner helper wrappers -# ============================================================================ -# Usage: with_spinner "Message" cmd arg... -# Set MOLE_SPINNER_PREFIX=" " for indented spinner (e.g., in clean context) -with_spinner() { - local msg="$1" - shift || true - local timeout="${MOLE_CMD_TIMEOUT:-180}" # Default 3min timeout - - if [[ -t 1 ]]; then - start_inline_spinner "$msg" - fi - - # Run command with timeout protection - if command -v timeout > /dev/null 2>&1; then - # GNU timeout available - timeout "$timeout" "$@" > /dev/null 2>&1 || { - local exit_code=$? - if [[ -t 1 ]]; then stop_inline_spinner; fi - # Exit code 124 means timeout - [[ $exit_code -eq 124 ]] && echo -e " ${YELLOW}${ICON_WARNING}${NC} $msg timed out (skipped)" >&2 - return $exit_code - } - else - # Fallback: run in background with manual timeout - "$@" > /dev/null 2>&1 & - local cmd_pid=$! - local elapsed=0 - while kill -0 $cmd_pid 2> /dev/null; do - if [[ $elapsed -ge $timeout ]]; then - kill -TERM $cmd_pid 2> /dev/null || true - wait $cmd_pid 2> /dev/null || true - if [[ -t 1 ]]; then stop_inline_spinner; fi - echo -e " ${YELLOW}${ICON_WARNING}${NC} $msg timed out (skipped)" >&2 - return 124 - fi - sleep 1 - ((elapsed++)) - done - wait $cmd_pid 2> /dev/null || { - local exit_code=$? - if [[ -t 1 ]]; then stop_inline_spinner; fi - return $exit_code - } - fi - - if [[ -t 1 ]]; then - stop_inline_spinner - fi -} - -# ============================================================================ -# Cache/tool cleanup abstraction -# ============================================================================ -# clean_tool_cache "Label" command... -clean_tool_cache() { - local label="$1" - shift || true - - # Calculate size before cleanup for statistics - # This improves "Space freed" reporting by including command-based cleanups - local size_before=0 - local cache_dirs=() - - # Detect cache directories based on tool name - case "$label" in - "npm cache") - cache_dirs=("$HOME/.npm/_cacache" "$HOME/.npm/_logs") - ;; - "pip cache") - cache_dirs=("$HOME/.cache/pip" "$HOME/Library/Caches/pip") - ;; - "Go cache") - cache_dirs=("$HOME/Library/Caches/go-build" "$HOME/go/pkg/mod/cache") - ;; - "Docker build cache") - # Docker cache size is calculated by docker itself, skip - ;; - "Homebrew") - cache_dirs=("$HOME/Library/Caches/Homebrew") - ;; - esac - - # Calculate total size of cache directories - if [[ ${#cache_dirs[@]} -gt 0 ]]; then - for dir in "${cache_dirs[@]}"; do - if [[ -d "$dir" ]]; then - local dir_size=$(get_path_size_kb "$dir" 2> /dev/null || echo "0") - ((size_before += dir_size)) - fi - done - fi - - if [[ "$DRY_RUN" == "true" ]]; then - if [[ $size_before -gt 0 ]]; then - local size_human=$(bytes_to_human "$((size_before * 1024))") - echo -e " ${YELLOW}→${NC} $label ${YELLOW}($size_human dry)${NC}" - ((total_size_cleaned += size_before)) || true - ((total_items++)) || true - else - echo -e " ${YELLOW}→${NC} $label (would clean)" - fi - return 0 - fi - - if MOLE_SPINNER_PREFIX=" " with_spinner "$label" "$@"; then - if [[ $size_before -gt 0 ]]; then - local size_human=$(bytes_to_human "$((size_before * 1024))") - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label ${GREEN}($size_human)${NC}" - ((total_size_cleaned += size_before)) || true - ((total_items++)) || true - else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label" - fi - else - local exit_code=$? - # Timeout returns 124, don't show error message (already shown by with_spinner) - if [[ $exit_code -ne 124 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} $label failed (skipped)" >&2 - fi - fi - return 0 # Always return success to continue cleanup -} - -# ============================================================================ -# Unified confirmation prompt with consistent style -# ============================================================================ - -# Unified action prompt -# Usage: prompt_action "action" "cancel_text" -> returns 0 for yes, 1 for no -# Example: prompt_action "enable" "quit" -> "☛ Press Enter to enable, ESC to quit: " -# Get optimal parallel job count based on CPU cores - -# ============================================================================ -# Size helpers -# ============================================================================ - -# Get path size in KB using du -# Args: $1 - path to measure -# Returns: size in KB, or 0 if path doesn't exist or error occurs -get_path_size_kb() { - local path="$1" - [[ -z "$path" || ! -e "$path" ]] && { - echo "0" - return - } - local result - # Timeout protection: prevent du from hanging on large directories - result=$(run_with_timeout 5 sh -c "command du -sk \"$path\" 2> /dev/null | awk '{print \$1}'") - echo "${result:-0}" -} - -bytes_to_human_kb() { bytes_to_human "$((${1:-0} * 1024))"; } - -# ============================================================================ -# mktemp unification wrappers (register access) -# ============================================================================ -register_temp_file() { MOLE_TEMP_FILES+=("$1"); } -register_temp_dir() { MOLE_TEMP_DIRS+=("$1"); } - -mktemp_file() { - local f - f=$(mktemp) || return 1 - register_temp_file "$f" - echo "$f" -} - -# ============================================================================ -# Uninstall helper abstractions -# ============================================================================ -force_kill_app() { - # Args: app_name [app_path]; tries graceful then force kill; returns 0 if stopped, 1 otherwise - local app_name="$1" - local app_path="${2:-}" - - # Get the executable name from bundle if app_path is provided - local exec_name="" - if [[ -n "$app_path" && -e "$app_path/Contents/Info.plist" ]]; then - exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "") - fi - - # Use executable name for precise matching, fallback to app name - local match_pattern="${exec_name:-$app_name}" - - # Check if process is running using exact match only - if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then - return 0 - fi - - # Try graceful termination first - pkill -x "$match_pattern" 2> /dev/null || true - sleep 2 - - # Check again after graceful kill - if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then - return 0 - fi - - # Force kill if still running - pkill -9 -x "$match_pattern" 2> /dev/null || true - sleep 2 - - # If still running and sudo is available, try with sudo - if pgrep -x "$match_pattern" > /dev/null 2>&1; then - if sudo -n true 2> /dev/null; then - sudo pkill -9 -x "$match_pattern" 2> /dev/null || true - sleep 2 - fi - fi - - # Final check with longer timeout for stubborn processes - local retries=3 - while [[ $retries -gt 0 ]]; do - if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then - return 0 - fi - sleep 1 - ((retries--)) - done - - # Still running after all attempts - pgrep -x "$match_pattern" > /dev/null 2>&1 && return 1 || return 0 -} - -# Remove application icons from the Dock (best effort) -remove_apps_from_dock() { - if [[ $# -eq 0 ]]; then - return 0 - fi - - local plist="$HOME/Library/Preferences/com.apple.dock.plist" - [[ -f "$plist" ]] || return 0 - - if ! command -v python3 > /dev/null 2>&1; then - return 0 - fi - - # Execute Python helper to prune dock entries for the given app paths. - # Exit status 2 means entries were removed. - local target_count=$# - - python3 - "$@" << 'PY' -import os -import plistlib -import subprocess -import sys -import urllib.parse - -plist_path = os.path.expanduser('~/Library/Preferences/com.apple.dock.plist') -if not os.path.exists(plist_path): - sys.exit(0) - -def normalise(path): - if not path: - return '' - return os.path.normpath(os.path.realpath(path.rstrip('/'))) - -targets = {normalise(arg) for arg in sys.argv[1:] if arg} -targets = {t for t in targets if t} -if not targets: - sys.exit(0) - -with open(plist_path, 'rb') as fh: - try: - data = plistlib.load(fh) - except Exception: - sys.exit(0) - -apps = data.get('persistent-apps') -if not isinstance(apps, list): - sys.exit(0) - -changed = False -filtered = [] -for item in apps: - try: - url = item['tile-data']['file-data']['_CFURLString'] - except (KeyError, TypeError): - filtered.append(item) - continue - - if not isinstance(url, str): - filtered.append(item) - continue - - parsed = urllib.parse.urlparse(url) - path = urllib.parse.unquote(parsed.path or '') - if not path: - filtered.append(item) - continue - - candidate = normalise(path) - if any(candidate == t or candidate.startswith(t + os.sep) for t in targets): - changed = True - continue - - filtered.append(item) - -if not changed: - sys.exit(0) - -data['persistent-apps'] = filtered -with open(plist_path, 'wb') as fh: - try: - plistlib.dump(data, fh, fmt=plistlib.FMT_BINARY) - except Exception: - plistlib.dump(data, fh) - -# Restart Dock to apply changes (ignore errors) -try: - subprocess.run(['killall', 'Dock'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) -except Exception: - pass - -sys.exit(2) -PY - local python_status=$? - if [[ $python_status -eq 2 ]]; then - if [[ $target_count -gt 1 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed app icons from Dock" - else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed app icon from Dock" - fi - return 0 - fi - return $python_status -} - -# Get optimal parallel job count based on CPU cores -get_optimal_parallel_jobs() { - local operation_type="${1:-default}" - local cpu_cores - cpu_cores=$(sysctl -n hw.ncpu 2> /dev/null || echo 4) - case "$operation_type" in - scan | io) - echo $((cpu_cores * 2)) - ;; - compute) - echo "$cpu_cores" - ;; - *) - echo $((cpu_cores + 2)) - ;; - esac -} - -# ============================================================================ -# Sudo Keepalive Management -# ============================================================================ - -# Start sudo keepalive process -# Returns: PID of the keepalive process -start_sudo_keepalive() { - ( - local retry_count=0 - while true; do - if ! sudo -n -v 2> /dev/null; then - ((retry_count++)) - if [[ $retry_count -ge 3 ]]; then - exit 1 - fi - sleep 5 - continue - fi - retry_count=0 - sleep 30 - kill -0 "$$" 2> /dev/null || exit - done - ) 2> /dev/null & - echo $! -} - -# Stop sudo keepalive process -# Args: $1 - PID of the keepalive process -stop_sudo_keepalive() { - local pid="${1:-}" - if [[ -n "$pid" ]]; then - kill "$pid" 2> /dev/null || true - wait "$pid" 2> /dev/null || true - fi -} - -# ============================================================================ -# Section Management -# ============================================================================ - -# Section tracking variables -TRACK_SECTION=0 -SECTION_ACTIVITY=0 - -# Start a new section -start_section() { - TRACK_SECTION=1 - SECTION_ACTIVITY=0 - echo "" - echo -e "${PURPLE_BOLD}${ICON_ARROW} $1${NC}" -} - -# End a section (show "Nothing to tidy" if no activity) -end_section() { - if [[ $TRACK_SECTION -eq 1 && $SECTION_ACTIVITY -eq 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Nothing to tidy" - fi - TRACK_SECTION=0 -} - -# Mark activity in current section -note_activity() { - if [[ $TRACK_SECTION -eq 1 ]]; then - SECTION_ACTIVITY=1 - fi -} - -# ============================================================================ -# App Management Functions -# ============================================================================ - -# System critical components that should NEVER be uninstalled -readonly SYSTEM_CRITICAL_BUNDLES=( - "com.apple.*" # System essentials - "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.QQInput" - "com.sogou.inputmethod.*" - "com.baidu.inputmethod.*" - "com.apple.inputmethod.*" - "com.googlecode.rimeime.*" - "im.rime.*" - "org.pqrs.Karabiner*" - "*.inputmethod" - "*.InputMethod" - "*IME" - "com.apple.inputsource*" - "com.apple.TextInputMenuAgent" - "com.apple.TextInputSwitcher" -) - -# Apps with important data/licenses - protect during cleanup but allow uninstall -readonly DATA_PROTECTED_BUNDLES=( - # ============================================================================ - # System Utilities & Cleanup Tools - # ============================================================================ - "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 - - # ============================================================================ - # Password Managers & Security - # ============================================================================ - "com.1password.*" # 1Password - "com.agilebits.*" # 1Password legacy - "com.lastpass.*" # LastPass - "com.dashlane.*" # Dashlane - "com.bitwarden.*" # Bitwarden - "com.keepassx.*" # KeePassXC - "org.keepassx.*" # KeePassX - "com.authy.*" # Authy - "com.yubico.*" # YubiKey Manager - - # ============================================================================ - # Development Tools - IDEs & Editors - # ============================================================================ - "com.jetbrains.*" # JetBrains IDEs (IntelliJ, DataGrip, etc.) - "JetBrains*" # JetBrains Application Support folders - "com.microsoft.VSCode" # Visual Studio Code - "com.visualstudio.code.*" # VS Code variants - "com.sublimetext.*" # Sublime Text - "com.sublimehq.*" # Sublime Merge - "com.microsoft.VSCodeInsiders" # VS Code Insiders - "com.apple.dt.Xcode" # Xcode (keep settings) - "com.coteditor.CotEditor" # CotEditor - "com.macromates.TextMate" # TextMate - "com.panic.Nova" # Nova - "abnerworks.Typora" # Typora (Markdown editor) - "com.uranusjr.macdown" # MacDown - - # ============================================================================ - # Development Tools - Database Clients - # ============================================================================ - "com.sequelpro.*" # Sequel Pro - "com.sequel-ace.*" # Sequel Ace - "com.tinyapp.*" # TablePlus - "com.dbeaver.*" # DBeaver - "com.navicat.*" # Navicat - "com.mongodb.compass" # MongoDB Compass - "com.redis.RedisInsight" # Redis Insight - "com.pgadmin.pgadmin4" # pgAdmin - "com.eggerapps.Sequel-Pro" # Sequel Pro legacy - "com.valentina-db.Valentina-Studio" # Valentina Studio - "com.dbvis.DbVisualizer" # DbVisualizer - - # ============================================================================ - # Development Tools - API & Network - # ============================================================================ - "com.postmanlabs.mac" # Postman - "com.konghq.insomnia" # Insomnia - "com.CharlesProxy.*" # Charles Proxy - "com.proxyman.*" # Proxyman - "com.getpaw.*" # Paw - "com.luckymarmot.Paw" # Paw legacy - "com.charlesproxy.charles" # Charles - "com.telerik.Fiddler" # Fiddler - "com.usebruno.app" # Bruno (API client) - - # Network Proxy & VPN Tools (protect all variants) - "*clash*" # All Clash variants (ClashX, ClashX Pro, Clash Verge, etc) - "*Clash*" # Capitalized variants - "com.nssurge.surge-mac" # Surge - "mihomo*" # Mihomo Party and variants - "*openvpn*" # OpenVPN Connect and variants - "*OpenVPN*" # OpenVPN capitalized variants - "net.openvpn.*" # OpenVPN bundle IDs - - # ============================================================================ - # Development Tools - Git & Version Control - # ============================================================================ - "com.github.GitHubDesktop" # GitHub Desktop - "com.sublimemerge" # Sublime Merge - "com.torusknot.SourceTreeNotMAS" # SourceTree - "com.git-tower.Tower*" # Tower - "com.gitfox.GitFox" # GitFox - "com.github.Gitify" # Gitify - "com.fork.Fork" # Fork - "com.axosoft.gitkraken" # GitKraken - - # ============================================================================ - # Development Tools - Terminal & Shell - # ============================================================================ - "com.googlecode.iterm2" # iTerm2 - "net.kovidgoyal.kitty" # Kitty - "io.alacritty" # Alacritty - "com.github.wez.wezterm" # WezTerm - "com.hyper.Hyper" # Hyper - "com.mizage.divvy" # Divvy - "com.fig.Fig" # Fig (terminal assistant) - "dev.warp.Warp-Stable" # Warp - "com.termius-dmg" # Termius (SSH client) - - # ============================================================================ - # Development Tools - Docker & Virtualization - # ============================================================================ - "com.docker.docker" # Docker Desktop - "com.getutm.UTM" # UTM - "com.vmware.fusion" # VMware Fusion - "com.parallels.desktop.*" # Parallels Desktop - "org.virtualbox.app.VirtualBox" # VirtualBox - "com.vagrant.*" # Vagrant - "com.orbstack.OrbStack" # OrbStack - - # ============================================================================ - # System Monitoring & Performance - # ============================================================================ - "com.bjango.istatmenus*" # iStat Menus - "eu.exelban.Stats" # Stats - "com.monitorcontrol.*" # MonitorControl - "com.bresink.system-toolkit.*" # TinkerTool System - "com.mediaatelier.MenuMeters" # MenuMeters - "com.activity-indicator.app" # Activity Indicator - "net.cindori.sensei" # Sensei - - # ============================================================================ - # Window Management & Productivity - # ============================================================================ - "com.macitbetter.*" # BetterTouchTool, BetterSnapTool - "com.hegenberg.*" # BetterTouchTool legacy - "com.manytricks.*" # Moom, Witch, Name Mangler, Resolutionator - "com.divisiblebyzero.*" # Spectacle - "com.koingdev.*" # Koingg apps - "com.if.Amphetamine" # Amphetamine - "com.lwouis.alt-tab-macos" # AltTab - "net.matthewpalmer.Vanilla" # Vanilla - "com.lightheadsw.Caffeine" # Caffeine - "com.contextual.Contexts" # Contexts - "com.amethyst.Amethyst" # Amethyst - "com.knollsoft.Rectangle" # Rectangle - "com.knollsoft.Hookshot" # Hookshot - "com.surteesstudios.Bartender" # Bartender - "com.gaosun.eul" # eul (system monitor) - "com.pointum.hazeover" # HazeOver - - # ============================================================================ - # Launcher & Automation - # ============================================================================ - "com.runningwithcrayons.Alfred" # Alfred - "com.raycast.macos" # Raycast - "com.blacktree.Quicksilver" # Quicksilver - "com.stairways.keyboardmaestro.*" # Keyboard Maestro - "com.manytricks.Butler" # Butler - "com.happenapps.Quitter" # Quitter - "com.pilotmoon.scroll-reverser" # Scroll Reverser - "org.pqrs.Karabiner-Elements" # Karabiner-Elements - "com.apple.Automator" # Automator (system, but keep user workflows) - - # ============================================================================ - # Note-Taking & Documentation - # ============================================================================ - "com.bear-writer.*" # Bear - "com.typora.*" # Typora - "com.ulyssesapp.*" # Ulysses - "com.literatureandlatte.*" # Scrivener - "com.dayoneapp.*" # Day One - "notion.id" # Notion - "md.obsidian" # Obsidian - "com.logseq.logseq" # Logseq - "com.evernote.Evernote" # Evernote - "com.onenote.mac" # OneNote - "com.omnigroup.OmniOutliner*" # OmniOutliner - "net.shinyfrog.bear" # Bear legacy - "com.goodnotes.GoodNotes" # GoodNotes - "com.marginnote.MarginNote*" # MarginNote - "com.roamresearch.*" # Roam Research - "com.reflect.ReflectApp" # Reflect - "com.inkdrop.*" # Inkdrop - - # ============================================================================ - # Design & Creative Tools - # ============================================================================ - "com.adobe.*" # Adobe Creative Suite - "com.bohemiancoding.*" # Sketch - "com.figma.*" # Figma - "com.framerx.*" # Framer - "com.zeplin.*" # Zeplin - "com.invisionapp.*" # InVision - "com.principle.*" # Principle - "com.pixelmatorteam.*" # Pixelmator - "com.affinitydesigner.*" # Affinity Designer - "com.affinityphoto.*" # Affinity Photo - "com.affinitypublisher.*" # Affinity Publisher - "com.linearity.curve" # Linearity Curve - "com.canva.CanvaDesktop" # Canva - "com.maxon.cinema4d" # Cinema 4D - "com.autodesk.*" # Autodesk products - "com.sketchup.*" # SketchUp - - # ============================================================================ - # Communication & Collaboration - # ============================================================================ - "com.tencent.xinWeChat" # WeChat (Chinese users) - "com.tencent.qq" # QQ - "com.alibaba.DingTalkMac" # DingTalk - "com.alibaba.AliLang.osx" # AliLang (retain login/config data) - "com.alibaba.alilang3.osx.ShipIt" # AliLang updater component - "com.alibaba.AlilangMgr.QueryNetworkInfo" # AliLang network helper - "us.zoom.xos" # Zoom - "com.microsoft.teams*" # Microsoft Teams - "com.slack.Slack" # Slack - "com.hnc.Discord" # Discord - "org.telegram.desktop" # Telegram - "ru.keepcoder.Telegram" # Telegram legacy - "net.whatsapp.WhatsApp" # WhatsApp - "com.skype.skype" # Skype - "com.cisco.webexmeetings" # Webex - "com.ringcentral.RingCentral" # RingCentral - "com.readdle.smartemail-Mac" # Spark Email - "com.airmail.*" # Airmail - "com.postbox-inc.postbox" # Postbox - "com.tinyspeck.slackmacgap" # Slack legacy - - # ============================================================================ - # Task Management & Productivity - # ============================================================================ - "com.omnigroup.OmniFocus*" # OmniFocus - "com.culturedcode.*" # Things - "com.todoist.*" # Todoist - "com.any.do.*" # Any.do - "com.ticktick.*" # TickTick - "com.microsoft.to-do" # Microsoft To Do - "com.trello.trello" # Trello - "com.asana.nativeapp" # Asana - "com.clickup.*" # ClickUp - "com.monday.desktop" # Monday.com - "com.airtable.airtable" # Airtable - "com.notion.id" # Notion (also note-taking) - "com.linear.linear" # Linear - - # ============================================================================ - # File Transfer & Sync - # ============================================================================ - "com.panic.transmit*" # Transmit (FTP/SFTP) - "com.binarynights.ForkLift*" # ForkLift - "com.noodlesoft.Hazel" # Hazel - "com.cyberduck.Cyberduck" # Cyberduck - "io.filezilla.FileZilla" # FileZilla - "com.apple.Xcode.CloudDocuments" # Xcode Cloud Documents - "com.synology.*" # Synology apps - - # ============================================================================ - # Screenshot & Recording - # ============================================================================ - "com.cleanshot.*" # CleanShot X - "com.xnipapp.xnip" # Xnip - "com.reincubate.camo" # Camo - "com.tunabellysoftware.ScreenFloat" # ScreenFloat - "net.telestream.screenflow*" # ScreenFlow - "com.techsmith.snagit*" # Snagit - "com.techsmith.camtasia*" # Camtasia - "com.obsidianapp.screenrecorder" # Screen Recorder - "com.kap.Kap" # Kap - "com.getkap.*" # Kap legacy - "com.linebreak.CloudApp" # CloudApp - "com.droplr.droplr-mac" # Droplr - - # ============================================================================ - # Media & Entertainment - # ============================================================================ - "com.spotify.client" # Spotify - "com.apple.Music" # Apple Music - "com.apple.podcasts" # Apple Podcasts - "com.apple.FinalCutPro" # Final Cut Pro - "com.apple.Motion" # Motion - "com.apple.Compressor" # Compressor - "com.blackmagic-design.*" # DaVinci Resolve - "com.colliderli.iina" # IINA - "org.videolan.vlc" # VLC - "io.mpv" # MPV - "com.noodlesoft.Hazel" # Hazel (automation) - "tv.plex.player.desktop" # Plex - "com.netease.163music" # NetEase Music - - # ============================================================================ - # License Management & App Stores - # ============================================================================ - "com.paddle.Paddle*" # Paddle (license management) - "com.setapp.DesktopClient" # Setapp - "com.devmate.*" # DevMate (license framework) - "org.sparkle-project.Sparkle" # Sparkle (update framework) -) - -# Legacy function - preserved for backward compatibility -# Use should_protect_from_uninstall() or should_protect_data() instead -readonly PRESERVED_BUNDLE_PATTERNS=("${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}") - -# Check whether a bundle ID matches a pattern (supports globs) -bundle_matches_pattern() { - local bundle_id="$1" - local pattern="$2" - - [[ -z "$pattern" ]] && return 1 - - # Use bash [[ ]] for glob pattern matching (works with variables in bash 3.2+) - # shellcheck disable=SC2053 # allow glob pattern matching - if [[ "$bundle_id" == $pattern ]]; then - return 0 - fi - return 1 -} - -# Check if app is a system component that should never be uninstalled -should_protect_from_uninstall() { - local bundle_id="$1" - for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do - if bundle_matches_pattern "$bundle_id" "$pattern"; then - return 0 - fi - done - return 1 -} - -# Check if app data should be protected during cleanup (but app can be uninstalled) -should_protect_data() { - local bundle_id="$1" - # Protect both system critical and data protected bundles during cleanup - for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do - if bundle_matches_pattern "$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=() - - # ============================================================================ - # User-level files (no sudo required) - # ============================================================================ - - # 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") - [[ -d ~/Library/Caches/"$app_name" ]] && files_to_clean+=("$HOME/Library/Caches/$app_name") - - # Preferences - [[ -f ~/Library/Preferences/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id.plist") - while IFS= read -r -d '' pref; do - files_to_clean+=("$pref") - done < <(command find ~/Library/Preferences/ByHost \( -name "$bundle_id*.plist" \) -print0 2> /dev/null) - - # 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") - - # Crash Reports and Diagnostics - while IFS= read -r -d '' report; do - files_to_clean+=("$report") - done < <(command find ~/Library/Logs/DiagnosticReports \( -name "*$app_name*" -o -name "*$bundle_id*" \) -print0 2> /dev/null) - - # 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 < <(command find ~/Library/Group\ Containers -type d \( -name "*$bundle_id*" \) -print0 2> /dev/null) - - # WebKit data - [[ -d ~/Library/WebKit/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/WebKit/$bundle_id") - [[ -d ~/Library/WebKit/com.apple.WebKit.WebContent/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/WebKit/com.apple.WebKit.WebContent/$bundle_id") - - # HTTP Storage - [[ -d ~/Library/HTTPStorages/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/HTTPStorages/$bundle_id") - - # Cookies - [[ -f ~/Library/Cookies/"$bundle_id".binarycookies ]] && files_to_clean+=("$HOME/Library/Cookies/$bundle_id.binarycookies") - - # Launch Agents (user-level) - [[ -f ~/Library/LaunchAgents/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/LaunchAgents/$bundle_id.plist") - - # Application Scripts - [[ -d ~/Library/Application\ Scripts/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Application Scripts/$bundle_id") - - # Services - [[ -d ~/Library/Services/"$app_name".workflow ]] && files_to_clean+=("$HOME/Library/Services/$app_name.workflow") - - # Internet Plug-Ins - while IFS= read -r -d '' plugin; do - files_to_clean+=("$plugin") - done < <(command find ~/Library/Internet\ Plug-Ins \( -name "$bundle_id*" -o -name "$app_name*" \) -print0 2> /dev/null) - - # QuickLook Plugins - [[ -d ~/Library/QuickLook/"$app_name".qlgenerator ]] && files_to_clean+=("$HOME/Library/QuickLook/$app_name.qlgenerator") - - # Preference Panes - [[ -d ~/Library/PreferencePanes/"$app_name".prefPane ]] && files_to_clean+=("$HOME/Library/PreferencePanes/$app_name.prefPane") - - # Screen Savers - [[ -d ~/Library/Screen\ Savers/"$app_name".saver ]] && files_to_clean+=("$HOME/Library/Screen Savers/$app_name.saver") - - # Frameworks - [[ -d ~/Library/Frameworks/"$app_name".framework ]] && files_to_clean+=("$HOME/Library/Frameworks/$app_name.framework") - - # CoreData - while IFS= read -r -d '' coredata; do - files_to_clean+=("$coredata") - done < <(command find ~/Library/CoreData \( -name "*$bundle_id*" -o -name "*$app_name*" \) -print0 2> /dev/null) - - # Autosave Information - [[ -d ~/Library/Autosave\ Information/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Autosave Information/$bundle_id") - - # Contextual Menu Items - [[ -d ~/Library/Contextual\ Menu\ Items/"$app_name".plugin ]] && files_to_clean+=("$HOME/Library/Contextual Menu Items/$app_name.plugin") - - # Receipts (user-level) - while IFS= read -r -d '' receipt; do - files_to_clean+=("$receipt") - done < <(command find ~/Library/Receipts \( -name "*$bundle_id*" -o -name "*$app_name*" \) -print0 2> /dev/null) - - # Spotlight Plugins - [[ -d ~/Library/Spotlight/"$app_name".mdimporter ]] && files_to_clean+=("$HOME/Library/Spotlight/$app_name.mdimporter") - - # Scripting Additions - while IFS= read -r -d '' scripting; do - files_to_clean+=("$scripting") - done < <(command find ~/Library/ScriptingAdditions \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # Color Pickers - [[ -d ~/Library/ColorPickers/"$app_name".colorPicker ]] && files_to_clean+=("$HOME/Library/ColorPickers/$app_name.colorPicker") - - # Quartz Compositions - while IFS= read -r -d '' composition; do - files_to_clean+=("$composition") - done < <(command find ~/Library/Compositions \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # Address Book Plug-Ins - while IFS= read -r -d '' plugin; do - files_to_clean+=("$plugin") - done < <(command find ~/Library/Address\ Book\ Plug-Ins \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # Mail Bundles - while IFS= read -r -d '' bundle; do - files_to_clean+=("$bundle") - done < <(command find ~/Library/Mail/Bundles \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # Input Managers (app-specific only) - while IFS= read -r -d '' manager; do - files_to_clean+=("$manager") - done < <(command find ~/Library/InputManagers \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # Custom Sounds - while IFS= read -r -d '' sound; do - files_to_clean+=("$sound") - done < <(command find ~/Library/Sounds \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # Plugins - while IFS= read -r -d '' plugin; do - files_to_clean+=("$plugin") - done < <(command find ~/Library/Plugins \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # Private Frameworks - while IFS= read -r -d '' framework; do - files_to_clean+=("$framework") - done < <(command find ~/Library/PrivateFrameworks \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # Audio Plug-Ins - while IFS= read -r -d '' plugin; do - files_to_clean+=("$plugin") - done < <(command find ~/Library/Audio/Plug-Ins \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # Components - while IFS= read -r -d '' component; do - files_to_clean+=("$component") - done < <(command find ~/Library/Components \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # Metadata - while IFS= read -r -d '' metadata; do - files_to_clean+=("$metadata") - done < <(command find ~/Library/Metadata \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # Workflows - [[ -d ~/Library/Workflows/"$app_name".workflow ]] && files_to_clean+=("$HOME/Library/Workflows/$app_name.workflow") - while IFS= read -r -d '' workflow; do - files_to_clean+=("$workflow") - done < <(command find ~/Library/Workflows \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # Favorites (excluding Safari) - while IFS= read -r -d '' favorite; do - # Skip Safari favorites - case "$favorite" in - *Safari*) continue ;; - esac - files_to_clean+=("$favorite") - done < <(command find ~/Library/Favorites \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # Unix-style configuration directories and files (cross-platform apps) - [[ -d ~/.config/"$app_name" ]] && files_to_clean+=("$HOME/.config/$app_name") - [[ -d ~/.local/share/"$app_name" ]] && files_to_clean+=("$HOME/.local/share/$app_name") - [[ -d ~/."$app_name" ]] && files_to_clean+=("$HOME/.$app_name") - [[ -f ~/."${app_name}rc" ]] && files_to_clean+=("$HOME/.${app_name}rc") - - # 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 -} - -# Find system-level app files (requires sudo) -find_app_system_files() { - local bundle_id="$1" - local app_name="$2" - local -a system_files=() - - # System Application Support - [[ -d /Library/Application\ Support/"$app_name" ]] && system_files+=("/Library/Application Support/$app_name") - [[ -d /Library/Application\ Support/"$bundle_id" ]] && system_files+=("/Library/Application Support/$bundle_id") - - # System Launch Agents - [[ -f /Library/LaunchAgents/"$bundle_id".plist ]] && system_files+=("/Library/LaunchAgents/$bundle_id.plist") - - # System Launch Daemons - [[ -f /Library/LaunchDaemons/"$bundle_id".plist ]] && system_files+=("/Library/LaunchDaemons/$bundle_id.plist") - - # Privileged Helper Tools - while IFS= read -r -d '' helper; do - system_files+=("$helper") - done < <(command find /Library/PrivilegedHelperTools \( -name "$bundle_id*" \) -print0 2> /dev/null) - - # System Preferences - [[ -f /Library/Preferences/"$bundle_id".plist ]] && system_files+=("/Library/Preferences/$bundle_id.plist") - - # Installation Receipts - while IFS= read -r -d '' receipt; do - system_files+=("$receipt") - done < <(command find /private/var/db/receipts \( -name "*$bundle_id*" \) -print0 2> /dev/null) - - # System Logs - [[ -d /Library/Logs/"$app_name" ]] && system_files+=("/Library/Logs/$app_name") - [[ -d /Library/Logs/"$bundle_id" ]] && system_files+=("/Library/Logs/$bundle_id") - - # System Crash Reports and Diagnostics - while IFS= read -r -d '' report; do - system_files+=("$report") - done < <(command find /Library/Logs/DiagnosticReports \( -name "*$app_name*" -o -name "*$bundle_id*" \) -print0 2> /dev/null) - - # System Frameworks - [[ -d /Library/Frameworks/"$app_name".framework ]] && system_files+=("/Library/Frameworks/$app_name.framework") - - # System Internet Plug-Ins - while IFS= read -r -d '' plugin; do - system_files+=("$plugin") - done < <(command find /Library/Internet\ Plug-Ins \( -name "$bundle_id*" -o -name "$app_name*" \) -print0 2> /dev/null) - - # System QuickLook Plugins - [[ -d /Library/QuickLook/"$app_name".qlgenerator ]] && system_files+=("/Library/QuickLook/$app_name.qlgenerator") - - # System Receipts - while IFS= read -r -d '' receipt; do - system_files+=("$receipt") - done < <(command find /Library/Receipts \( -name "*$bundle_id*" -o -name "*$app_name*" \) -print0 2> /dev/null) - - # System Spotlight Plugins - [[ -d /Library/Spotlight/"$app_name".mdimporter ]] && system_files+=("/Library/Spotlight/$app_name.mdimporter") - - # System Scripting Additions - while IFS= read -r -d '' scripting; do - system_files+=("$scripting") - done < <(command find /Library/ScriptingAdditions \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # System Color Pickers - [[ -d /Library/ColorPickers/"$app_name".colorPicker ]] && system_files+=("/Library/ColorPickers/$app_name.colorPicker") - - # System Quartz Compositions - while IFS= read -r -d '' composition; do - system_files+=("$composition") - done < <(command find /Library/Compositions \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # System Address Book Plug-Ins - while IFS= read -r -d '' plugin; do - system_files+=("$plugin") - done < <(command find /Library/Address\ Book\ Plug-Ins \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # System Mail Bundles - while IFS= read -r -d '' bundle; do - system_files+=("$bundle") - done < <(command find /Library/Mail/Bundles \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # System Input Managers - while IFS= read -r -d '' manager; do - system_files+=("$manager") - done < <(command find /Library/InputManagers \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # System Sounds - while IFS= read -r -d '' sound; do - system_files+=("$sound") - done < <(command find /Library/Sounds \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # System Contextual Menu Items - while IFS= read -r -d '' item; do - system_files+=("$item") - done < <(command find /Library/Contextual\ Menu\ Items \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # System Preference Panes - [[ -d /Library/PreferencePanes/"$app_name".prefPane ]] && system_files+=("/Library/PreferencePanes/$app_name.prefPane") - - # System Screen Savers - [[ -d /Library/Screen\ Savers/"$app_name".saver ]] && system_files+=("/Library/Screen Savers/$app_name.saver") - - # System Caches - [[ -d /Library/Caches/"$bundle_id" ]] && system_files+=("/Library/Caches/$bundle_id") - [[ -d /Library/Caches/"$app_name" ]] && system_files+=("/Library/Caches/$app_name") - - # System Audio Plug-Ins - while IFS= read -r -d '' plugin; do - system_files+=("$plugin") - done < <(command find /Library/Audio/Plug-Ins \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # System Components - while IFS= read -r -d '' component; do - system_files+=("$component") - done < <(command find /Library/Components \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # System Extensions - while IFS= read -r -d '' extension; do - system_files+=("$extension") - done < <(command find /Library/Extensions \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null) - - # Only print if array has elements - if [[ ${#system_files[@]} -gt 0 ]]; then - printf '%s\n' "${system_files[@]}" - 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 - size_kb=$(get_path_size_kb "$file") - ((total_kb += size_kb)) - fi - done <<< "$files" - - echo "$total_kb" -} - -# Get normalized brand name (bash 3.2 compatible using case statement) -get_brand_name() { - local name="$1" - - # Brand name mapping for better user recognition - case "$name" in - "qiyimac" | "爱奇艺") echo "iQiyi" ;; - "wechat" | "微信") echo "WeChat" ;; - "QQ") echo "QQ" ;; - "VooV Meeting" | "腾讯会议") echo "VooV Meeting" ;; - "dingtalk" | "钉钉") echo "DingTalk" ;; - "NeteaseMusic" | "网易云音乐") echo "NetEase Music" ;; - "BaiduNetdisk" | "百度网盘") echo "Baidu NetDisk" ;; - "alipay" | "支付宝") echo "Alipay" ;; - "taobao" | "淘宝") echo "Taobao" ;; - "futunn" | "富途牛牛") echo "Futu NiuNiu" ;; - "tencent lemon" | "Tencent Lemon Cleaner") echo "Tencent Lemon" ;; - "keynote" | "Keynote") echo "Keynote" ;; - "pages" | "Pages") echo "Pages" ;; - "numbers" | "Numbers") echo "Numbers" ;; - *) echo "$name" ;; # Return original if no mapping found - esac + # Clear update cache + rm -f "$HOME/.cache/mole/version_check" "$HOME/.cache/mole/update_message" 2> /dev/null || true } diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh new file mode 100644 index 0000000..615caf5 --- /dev/null +++ b/lib/core/file_ops.sh @@ -0,0 +1,291 @@ +#!/bin/bash +# Mole - File Operations +# Safe file and directory manipulation with validation + +set -euo pipefail + +# Prevent multiple sourcing +if [[ -n "${MOLE_FILE_OPS_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_FILE_OPS_LOADED=1 + +# Ensure dependencies are loaded +_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -z "${MOLE_BASE_LOADED:-}" ]]; then + # shellcheck source=lib/core/base.sh + source "$_MOLE_CORE_DIR/base.sh" +fi +if [[ -z "${MOLE_LOG_LOADED:-}" ]]; then + # shellcheck source=lib/core/log.sh + source "$_MOLE_CORE_DIR/log.sh" +fi +if [[ -z "${MOLE_TIMEOUT_LOADED:-}" ]]; then + # shellcheck source=lib/core/timeout.sh + source "$_MOLE_CORE_DIR/timeout.sh" +fi + +# ============================================================================ +# Path Validation +# ============================================================================ + +# Validate path for deletion operations +# Checks: non-empty, absolute, no traversal, no control chars, not system dir +# +# Args: $1 - path to validate +# Returns: 0 if safe, 1 if unsafe +validate_path_for_deletion() { + local path="$1" + + # Check path is not empty + if [[ -z "$path" ]]; then + log_error "Path validation failed: empty path" + return 1 + fi + + # Check path is absolute + if [[ "$path" != /* ]]; then + log_error "Path validation failed: path must be absolute: $path" + return 1 + fi + + # Check for path traversal attempts + if [[ "$path" =~ \.\. ]]; then + log_error "Path validation failed: path traversal not allowed: $path" + return 1 + fi + + # Check path doesn't contain dangerous characters + if [[ "$path" =~ [[:cntrl:]] ]] || [[ "$path" =~ $'\n' ]]; then + log_error "Path validation failed: contains control characters: $path" + return 1 + fi + + # Check path isn't critical system directory + case "$path" in + / | /bin | /sbin | /usr | /usr/bin | /usr/sbin | /etc | /var | /System | /System/* | /Library/Extensions) + log_error "Path validation failed: critical system directory: $path" + return 1 + ;; + esac + + return 0 +} + +# ============================================================================ +# Safe Removal Operations +# ============================================================================ + +# Safe wrapper around rm -rf with path validation +# +# Args: +# $1 - path to remove +# $2 - silent mode (optional, default: false) +# +# Returns: 0 on success, 1 on failure +safe_remove() { + local path="$1" + local silent="${2:-false}" + + # Validate path + if ! validate_path_for_deletion "$path"; then + return 1 + fi + + # Check if path exists + if [[ ! -e "$path" ]]; then + return 0 + fi + + debug_log "Removing: $path" + + # Perform the deletion + if rm -rf "$path" 2> /dev/null; then + return 0 + else + [[ "$silent" != "true" ]] && log_error "Failed to remove: $path" + return 1 + fi +} + +# Safe sudo remove with additional symlink protection +# +# Args: $1 - path to remove +# Returns: 0 on success, 1 on failure +safe_sudo_remove() { + local path="$1" + + # Validate path + if ! validate_path_for_deletion "$path"; then + log_error "Path validation failed for sudo remove: $path" + return 1 + fi + + # Check if path exists + if [[ ! -e "$path" ]]; then + return 0 + fi + + # Additional check: reject symlinks for sudo operations + if [[ -L "$path" ]]; then + log_error "Refusing to sudo remove symlink: $path" + return 1 + fi + + debug_log "Removing (sudo): $path" + + # Perform the deletion + if sudo rm -rf "$path" 2> /dev/null; then + return 0 + else + log_error "Failed to remove (sudo): $path" + return 1 + fi +} + +# ============================================================================ +# Safe Find and Delete Operations +# ============================================================================ + +# Safe find delete with depth limit and validation +# +# Args: +# $1 - base directory +# $2 - file pattern (e.g., "*.log") +# $3 - age in days (0 = all files, default: 7) +# $4 - type filter ("f" or "d", default: "f") +# +# Returns: 0 on success, 1 on failure +safe_find_delete() { + local base_dir="$1" + local pattern="$2" + local age_days="${3:-7}" + local type_filter="${4:-f}" + + # Validate base directory exists and is not a symlink + if [[ ! -d "$base_dir" ]]; then + log_error "Directory does not exist: $base_dir" + return 1 + fi + + if [[ -L "$base_dir" ]]; then + log_error "Refusing to search symlinked directory: $base_dir" + return 1 + fi + + # Validate type filter + if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then + log_error "Invalid type filter: $type_filter (must be 'f' or 'd')" + return 1 + fi + + debug_log "Finding in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)" + + # Execute find with safety limits (maxdepth 5 covers most app cache structures) + if [[ "$age_days" -eq 0 ]]; then + # Delete all matching files without time restriction + command find "$base_dir" \ + -maxdepth 5 \ + -name "$pattern" \ + -type "$type_filter" \ + -delete 2> /dev/null || true + else + # Delete files older than age_days + command find "$base_dir" \ + -maxdepth 5 \ + -name "$pattern" \ + -type "$type_filter" \ + -mtime "+$age_days" \ + -delete 2> /dev/null || true + fi + + return 0 +} + +# Safe sudo find delete (same as safe_find_delete but with sudo) +# +# Args: same as safe_find_delete +# Returns: 0 on success, 1 on failure +safe_sudo_find_delete() { + local base_dir="$1" + local pattern="$2" + local age_days="${3:-7}" + local type_filter="${4:-f}" + + # Validate base directory + if [[ ! -d "$base_dir" ]]; then + log_error "Directory does not exist: $base_dir" + return 1 + fi + + if [[ -L "$base_dir" ]]; then + log_error "Refusing to search symlinked directory: $base_dir" + return 1 + fi + + # Validate type filter + if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then + log_error "Invalid type filter: $type_filter (must be 'f' or 'd')" + return 1 + fi + + debug_log "Finding (sudo) in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)" + + # Execute find with sudo + if [[ "$age_days" -eq 0 ]]; then + sudo find "$base_dir" \ + -maxdepth 5 \ + -name "$pattern" \ + -type "$type_filter" \ + -delete 2> /dev/null || true + else + sudo find "$base_dir" \ + -maxdepth 5 \ + -name "$pattern" \ + -type "$type_filter" \ + -mtime "+$age_days" \ + -delete 2> /dev/null || true + fi + + return 0 +} + +# ============================================================================ +# Size Calculation +# ============================================================================ + +# Get path size in kilobytes +# Uses timeout protection to prevent du from hanging on large directories +# +# Args: $1 - path +# Returns: size in KB (0 if path doesn't exist) +get_path_size_kb() { + local path="$1" + [[ -z "$path" || ! -e "$path" ]] && { + echo "0" + return + } + # Direct execution without timeout overhead - critical for performance in loops + local size + size=$(command du -sk "$path" 2> /dev/null | awk '{print $1}') + echo "${size:-0}" +} + +# Calculate total size of multiple paths +# +# Args: $1 - newline-separated list of paths +# Returns: total size in KB +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 + size_kb=$(get_path_size_kb "$file") + ((total_kb += size_kb)) + fi + done <<< "$files" + + echo "$total_kb" +} diff --git a/lib/core/log.sh b/lib/core/log.sh new file mode 100644 index 0000000..708a83b --- /dev/null +++ b/lib/core/log.sh @@ -0,0 +1,153 @@ +#!/bin/bash +# Mole - Logging System +# Centralized logging with rotation support + +set -euo pipefail + +# Prevent multiple sourcing +if [[ -n "${MOLE_LOG_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_LOG_LOADED=1 + +# Ensure base.sh is loaded for colors and icons +if [[ -z "${MOLE_BASE_LOADED:-}" ]]; then + _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # shellcheck source=lib/core/base.sh + source "$_MOLE_CORE_DIR/base.sh" +fi + +# ============================================================================ +# Logging Configuration +# ============================================================================ + +readonly LOG_FILE="${HOME}/.config/mole/mole.log" +readonly LOG_MAX_SIZE_DEFAULT=1048576 # 1MB + +# Ensure log directory exists +mkdir -p "$(dirname "$LOG_FILE")" 2> /dev/null || true + +# ============================================================================ +# Log Rotation +# ============================================================================ + +# Rotate log file if it exceeds max size +# Called once at module load, not per log entry +rotate_log_once() { + # Skip if already checked this session + [[ -n "${MOLE_LOG_ROTATED:-}" ]] && return 0 + export MOLE_LOG_ROTATED=1 + + local max_size="${MOLE_MAX_LOG_SIZE:-$LOG_MAX_SIZE_DEFAULT}" + if [[ -f "$LOG_FILE" ]] && [[ $(get_file_size "$LOG_FILE") -gt "$max_size" ]]; then + mv "$LOG_FILE" "${LOG_FILE}.old" 2> /dev/null || true + touch "$LOG_FILE" 2> /dev/null || true + fi +} + +# ============================================================================ +# Logging Functions +# ============================================================================ + +# Log informational message +# Args: $1 - message +log_info() { + echo -e "${BLUE}$1${NC}" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] INFO: $1" >> "$LOG_FILE" 2> /dev/null || true +} + +# Log success message +# Args: $1 - message +log_success() { + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] SUCCESS: $1" >> "$LOG_FILE" 2> /dev/null || true +} + +# Log warning message +# Args: $1 - message +log_warning() { + echo -e "${YELLOW}$1${NC}" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARNING: $1" >> "$LOG_FILE" 2> /dev/null || true +} + +# Log error message +# Args: $1 - message +log_error() { + echo -e "${RED}${ICON_ERROR}${NC} $1" >&2 + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true +} + +# Debug logging - only shown when MO_DEBUG=1 +# Args: $@ - debug message components +debug_log() { + if [[ "${MO_DEBUG:-}" == "1" ]]; then + echo -e "${GRAY}[DEBUG]${NC} $*" >&2 + fi +} + +# ============================================================================ +# Command Execution Wrappers +# ============================================================================ + +# Run command silently, ignore errors +# Args: $@ - command and arguments +run_silent() { + "$@" > /dev/null 2>&1 || true +} + +# Run command with error logging +# Args: $@ - command and arguments +# Returns: command exit code +run_logged() { + local cmd="$1" + if ! "$@" 2>&1 | tee -a "$LOG_FILE" > /dev/null; then + log_warning "Command failed: $cmd" + return 1 + fi + return 0 +} + +# ============================================================================ +# Formatted Output +# ============================================================================ + +# Print formatted summary block with heading and details +# Args: $1=status (ignored), $2=heading, $@=details +print_summary_block() { + local heading="" + local -a details=() + local saw_heading=false + + # Parse arguments + for arg in "$@"; do + if [[ "$saw_heading" == "false" ]]; then + saw_heading=true + heading="$arg" + else + details+=("$arg") + fi + done + + local divider="======================================================================" + + # Print with dividers + echo "" + echo "$divider" + if [[ -n "$heading" ]]; then + echo -e "${BLUE}${heading}${NC}" + fi + + # Print details + for detail in "${details[@]}"; do + [[ -z "$detail" ]] && continue + echo -e "${detail}" + done + echo "$divider" +} + +# ============================================================================ +# Initialize Logging +# ============================================================================ + +# Perform log rotation check on module load +rotate_log_once diff --git a/lib/core/sudo.sh b/lib/core/sudo.sh index f2ab52c..da443db 100644 --- a/lib/core/sudo.sh +++ b/lib/core/sudo.sh @@ -4,6 +4,169 @@ set -euo pipefail +# ============================================================================ +# Touch ID and Clamshell Detection +# ============================================================================ + +check_touchid_support() { + if [[ -f /etc/pam.d/sudo ]]; then + grep -q "pam_tid.so" /etc/pam.d/sudo 2> /dev/null + return $? + fi + return 1 +} + +is_clamshell_mode() { + # ioreg is missing (not macOS) -> treat as lid open + if ! command -v ioreg > /dev/null 2>&1; then + return 1 + fi + + # Check if lid is closed; ignore pipeline failures so set -e doesn't exit + local clamshell_state="" + clamshell_state=$( (ioreg -r -k AppleClamshellState -d 4 2> /dev/null | + grep "AppleClamshellState" | + head -1) || true) + + if [[ "$clamshell_state" =~ \"AppleClamshellState\"\ =\ Yes ]]; then + return 0 # Lid is closed + fi + return 1 # Lid is open +} + +_request_password() { + local tty_path="$1" + local attempts=0 + local show_hint=true + + # Extra safety: ensure sudo cache is cleared before password input + sudo -k 2> /dev/null + + while ((attempts < 3)); do + local password="" + + # Show hint on first attempt about Touch ID appearing again + if [[ $show_hint == true ]] && check_touchid_support; then + echo -e "${GRAY}Note: Touch ID dialog may appear once more - just cancel it${NC}" > "$tty_path" + show_hint=false + fi + + printf "${PURPLE}${ICON_ARROW}${NC} Password: " > "$tty_path" + IFS= read -r -s password < "$tty_path" || password="" + printf "\n" > "$tty_path" + + if [[ -z "$password" ]]; then + unset password + ((attempts++)) + if [[ $attempts -lt 3 ]]; then + echo -e "${YELLOW}${ICON_WARNING}${NC} Password cannot be empty" > "$tty_path" + fi + continue + fi + + # Verify password with sudo + # NOTE: macOS PAM will trigger Touch ID before password auth - this is system behavior + if printf '%s\n' "$password" | sudo -S -p "" -v > /dev/null 2>&1; then + unset password + return 0 + fi + + unset password + ((attempts++)) + if [[ $attempts -lt 3 ]]; then + echo -e "${YELLOW}${ICON_WARNING}${NC} Incorrect password, try again" > "$tty_path" + fi + done + + return 1 +} + +request_sudo_access() { + local prompt_msg="${1:-Admin access required}" + + # Check if already have sudo access + if sudo -n true 2> /dev/null; then + return 0 + fi + + # Get TTY path + local tty_path="/dev/tty" + if [[ ! -r "$tty_path" || ! -w "$tty_path" ]]; then + tty_path=$(tty 2> /dev/null || echo "") + if [[ -z "$tty_path" || ! -r "$tty_path" || ! -w "$tty_path" ]]; then + log_error "No interactive terminal available" + return 1 + fi + fi + + sudo -k + + # Check if in clamshell mode - if yes, skip Touch ID entirely + if is_clamshell_mode; then + echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg}" + _request_password "$tty_path" + return $? + fi + + # Not in clamshell mode - try Touch ID if configured + if ! check_touchid_support; then + echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg}" + _request_password "$tty_path" + return $? + fi + + # Touch ID is available and not in clamshell mode + echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg} ${GRAY}(Touch ID or password)${NC}" + + # Start sudo in background so we can monitor and control it + sudo -v < /dev/null > /dev/null 2>&1 & + local sudo_pid=$! + + # Wait for sudo to complete or timeout (5 seconds) + local elapsed=0 + local timeout=50 # 50 * 0.1s = 5 seconds + while ((elapsed < timeout)); do + if ! kill -0 "$sudo_pid" 2> /dev/null; then + # Process exited + wait "$sudo_pid" 2> /dev/null + local exit_code=$? + if [[ $exit_code -eq 0 ]] && sudo -n true 2> /dev/null; then + # Touch ID succeeded + return 0 + fi + # Touch ID failed or cancelled + break + fi + sleep 0.1 + ((elapsed++)) + done + + # Touch ID failed/cancelled - clean up thoroughly before password input + + # Kill the sudo process if still running + if kill -0 "$sudo_pid" 2> /dev/null; then + kill -9 "$sudo_pid" 2> /dev/null + wait "$sudo_pid" 2> /dev/null || true + fi + + # Clear sudo state immediately + sudo -k 2> /dev/null + + # IMPORTANT: Wait longer for macOS to fully close Touch ID UI and SecurityAgent + # Without this delay, subsequent sudo calls may re-trigger Touch ID + sleep 1 + + # Clear any leftover prompts on the screen + printf "\r\033[2K" > "$tty_path" + + # Now use our password input (this should not trigger Touch ID again) + _request_password "$tty_path" +} + +# ============================================================================ +# Sudo Session Management +# ============================================================================ + # Global state MOLE_SUDO_KEEPALIVE_PID="" MOLE_SUDO_ESTABLISHED="false" diff --git a/lib/core/timeout.sh b/lib/core/timeout.sh new file mode 100644 index 0000000..abc8c0b --- /dev/null +++ b/lib/core/timeout.sh @@ -0,0 +1,156 @@ +#!/bin/bash +# Mole - Timeout Control +# Command execution with timeout support + +set -euo pipefail + +# Prevent multiple sourcing +if [[ -n "${MOLE_TIMEOUT_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_TIMEOUT_LOADED=1 + +# ============================================================================ +# Timeout Command Initialization +# ============================================================================ + +# Initialize timeout command (prefer gtimeout from coreutils, fallback to timeout) +# Sets MO_TIMEOUT_BIN to the available timeout command +# +# Recommendation: Install coreutils for reliable timeout support +# brew install coreutils +# +# The shell-based fallback has known limitations: +# - May not clean up all child processes +# - Has race conditions in edge cases +# - Less reliable than native timeout command +if [[ -z "${MO_TIMEOUT_INITIALIZED:-}" ]]; then + MO_TIMEOUT_BIN="" + for candidate in gtimeout timeout; do + if command -v "$candidate" > /dev/null 2>&1; then + MO_TIMEOUT_BIN="$candidate" + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo "[TIMEOUT] Using command: $candidate" >&2 + fi + break + fi + done + + # Log warning if no timeout command available + if [[ -z "$MO_TIMEOUT_BIN" ]] && [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo "[TIMEOUT] No timeout command found, using shell fallback" >&2 + echo "[TIMEOUT] Install coreutils for better reliability: brew install coreutils" >&2 + fi + + export MO_TIMEOUT_INITIALIZED=1 +fi + +# ============================================================================ +# Timeout Execution +# ============================================================================ + +# Run command with timeout +# Uses gtimeout/timeout if available, falls back to shell-based implementation +# +# Args: +# $1 - duration in seconds (0 or invalid = no timeout) +# $@ - command and arguments to execute +# +# Returns: +# Command exit code, or 124 if timed out (matches gtimeout behavior) +# +# Environment: +# MO_DEBUG - Set to 1 to enable debug logging to stderr +# +# Implementation notes: +# - Prefers gtimeout (coreutils) or timeout for reliability +# - Shell fallback uses SIGTERM → SIGKILL escalation +# - Attempts process group cleanup to handle child processes +# - Returns exit code 124 on timeout (standard timeout exit code) +# +# Known limitations of shell-based fallback: +# - Race condition: If command exits during signal delivery, the signal +# may target a reused PID (very rare, requires quick PID reuse) +# - Zombie processes: Brief zombies until wait completes +# - Nested children: SIGKILL may not reach all descendants +# - No process group: Cannot guarantee cleanup of detached children +# +# For mission-critical timeouts, install coreutils. +run_with_timeout() { + local duration="${1:-0}" + shift || true + + # No timeout if duration is invalid or zero + if [[ ! "$duration" =~ ^[0-9]+$ ]] || [[ "$duration" -le 0 ]]; then + "$@" + return $? + fi + + # Use timeout command if available (preferred path) + if [[ -n "${MO_TIMEOUT_BIN:-}" ]]; then + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo "[TIMEOUT] Running with ${duration}s timeout: $*" >&2 + fi + "$MO_TIMEOUT_BIN" "$duration" "$@" + return $? + fi + + # ======================================================================== + # Shell-based fallback implementation + # ======================================================================== + + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo "[TIMEOUT] Shell fallback (${duration}s): $*" >&2 + fi + + # Start command in background + "$@" & + local cmd_pid=$! + + # Start timeout killer in background + ( + # Wait for timeout duration + sleep "$duration" + + # Check if process still exists + if kill -0 "$cmd_pid" 2> /dev/null; then + # Try to kill process group first (negative PID), fallback to single process + # Process group kill is best effort - may not work if setsid was used + kill -TERM -"$cmd_pid" 2> /dev/null || kill -TERM "$cmd_pid" 2> /dev/null || true + + # Grace period for clean shutdown + sleep 2 + + # Escalate to SIGKILL if still alive + if kill -0 "$cmd_pid" 2> /dev/null; then + kill -KILL -"$cmd_pid" 2> /dev/null || kill -KILL "$cmd_pid" 2> /dev/null || true + fi + fi + ) & + local killer_pid=$! + + # Wait for command to complete + local exit_code=0 + set +e + wait "$cmd_pid" 2> /dev/null + exit_code=$? + set -e + + # Clean up killer process + if kill -0 "$killer_pid" 2> /dev/null; then + kill "$killer_pid" 2> /dev/null || true + wait "$killer_pid" 2> /dev/null || true + fi + + # Check if command was killed by timeout (exit codes 143=SIGTERM, 137=SIGKILL) + if [[ $exit_code -eq 143 || $exit_code -eq 137 ]]; then + # Command was killed by timeout + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo "[TIMEOUT] Command timed out after ${duration}s" >&2 + fi + return 124 + fi + + # Command completed normally (or with its own error) + return "$exit_code" +} diff --git a/lib/core/ui.sh b/lib/core/ui.sh new file mode 100755 index 0000000..fb4589e --- /dev/null +++ b/lib/core/ui.sh @@ -0,0 +1,171 @@ +#!/bin/bash +# Mole - UI Components +# Terminal UI utilities: cursor control, keyboard input, spinners, menus + +set -euo pipefail + +if [[ -n "${MOLE_UI_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_UI_LOADED=1 + +_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[[ -z "${MOLE_BASE_LOADED:-}" ]] && source "$_MOLE_CORE_DIR/base.sh" + +# Cursor control +clear_screen() { printf '\033[2J\033[H'; } +hide_cursor() { [[ -t 1 ]] && printf '\033[?25l' >&2 || true; } +show_cursor() { [[ -t 1 ]] && printf '\033[?25h' >&2 || true; } + +# Keyboard input - read single keypress +read_key() { + local key rest read_status + IFS= read -r -s -n 1 key + read_status=$? + [[ $read_status -ne 0 ]] && { + echo "QUIT" + return 0 + } + + if [[ "${MOLE_READ_KEY_FORCE_CHAR:-}" == "1" ]]; then + [[ -z "$key" ]] && { + echo "ENTER" + return 0 + } + case "$key" in + $'\n' | $'\r') echo "ENTER" ;; + $'\x7f' | $'\x08') echo "DELETE" ;; + $'\x1b') echo "QUIT" ;; + [[:print:]]) echo "CHAR:$key" ;; + *) echo "OTHER" ;; + esac + return 0 + fi + + [[ -z "$key" ]] && { + echo "ENTER" + return 0 + } + case "$key" in + $'\n' | $'\r') echo "ENTER" ;; + ' ') echo "SPACE" ;; + $'\x03') echo "QUIT" ;; + $'\x7f' | $'\x08') echo "DELETE" ;; + $'\x1b') + if IFS= read -r -s -n 1 -t 1 rest 2> /dev/null; then + if [[ "$rest" == "[" ]]; then + if IFS= read -r -s -n 1 -t 1 rest2 2> /dev/null; then + case "$rest2" in + "A") echo "UP" ;; "B") echo "DOWN" ;; + "C") echo "RIGHT" ;; "D") echo "LEFT" ;; + "3") + IFS= read -r -s -n 1 -t 1 rest3 2> /dev/null + [[ "$rest3" == "~" ]] && echo "DELETE" || echo "OTHER" + ;; + *) echo "OTHER" ;; + esac + else echo "QUIT"; fi + elif [[ "$rest" == "O" ]]; then + if IFS= read -r -s -n 1 -t 1 rest2 2> /dev/null; then + case "$rest2" 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 "QUIT"; fi + ;; + [[:print:]]) echo "CHAR:$key" ;; + *) echo "OTHER" ;; + esac +} + +drain_pending_input() { + local drained=0 + while IFS= read -r -s -n 1 -t 0.01 _ 2> /dev/null; do + ((drained++)) + [[ $drained -gt 100 ]] && break + done +} + +# Menu display +show_menu_option() { + local number="$1" + local text="$2" + local selected="$3" + + if [[ "$selected" == "true" ]]; then + echo -e "${CYAN}${ICON_ARROW} $number. $text${NC}" + else + echo " $number. $text" + fi +} + +# Inline spinner +INLINE_SPINNER_PID="" +start_inline_spinner() { + stop_inline_spinner 2> /dev/null || true + local message="$1" + + if [[ -t 1 ]]; then + ( + trap 'exit 0' TERM INT EXIT + local chars + chars="$(mo_spinner_chars)" + [[ -z "$chars" ]] && chars="|/-\\" + local i=0 + while true; do + local c="${chars:$((i % ${#chars})):1}" + # Output to stderr to avoid interfering with stdout + printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$message" >&2 || exit 0 + ((i++)) + # macOS supports decimal sleep, this is the primary target + sleep 0.1 2> /dev/null || sleep 1 2> /dev/null || exit 0 + done + ) & + INLINE_SPINNER_PID=$! + disown 2> /dev/null || true + else + echo -n " ${BLUE}|${NC} $message" >&2 + fi +} + +stop_inline_spinner() { + if [[ -n "$INLINE_SPINNER_PID" ]]; then + # Try graceful TERM first, then force KILL if needed + if kill -0 "$INLINE_SPINNER_PID" 2> /dev/null; then + kill -TERM "$INLINE_SPINNER_PID" 2> /dev/null || true + sleep 0.05 2> /dev/null || true + # Force kill if still running + if kill -0 "$INLINE_SPINNER_PID" 2> /dev/null; then + kill -KILL "$INLINE_SPINNER_PID" 2> /dev/null || true + fi + fi + wait "$INLINE_SPINNER_PID" 2> /dev/null || true + INLINE_SPINNER_PID="" + # Clear the line - use \033[2K to clear entire line, not just to end + [[ -t 1 ]] && printf "\r\033[2K" >&2 + fi +} + +# Wrapper for running commands with spinner +with_spinner() { + local msg="$1" + shift || true + local timeout="${MOLE_CMD_TIMEOUT:-180}" + start_inline_spinner "$msg" + local exit_code=0 + if [[ -n "${MOLE_TIMEOUT_BIN:-}" ]]; then + "$MOLE_TIMEOUT_BIN" "$timeout" "$@" > /dev/null 2>&1 || exit_code=$? + else "$@" > /dev/null 2>&1 || exit_code=$?; fi + stop_inline_spinner "$msg" + return $exit_code +} + +# Get spinner characters +mo_spinner_chars() { + local chars="${MO_SPINNER_CHARS:-|/-\\}" + [[ -z "$chars" ]] && chars="|/-\\" + printf "%s" "$chars" +}