mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 15:39:42 +00:00
Restructure common split content
This commit is contained in:
@@ -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
|
||||
|
||||
622
lib/core/app_protection.sh
Executable file
622
lib/core/app_protection.sh
Executable file
@@ -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"
|
||||
}
|
||||
339
lib/core/base.sh
Normal file
339
lib/core/base.sh
Normal file
@@ -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
|
||||
}
|
||||
2241
lib/core/common.sh
2241
lib/core/common.sh
File diff suppressed because it is too large
Load Diff
291
lib/core/file_ops.sh
Normal file
291
lib/core/file_ops.sh
Normal file
@@ -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"
|
||||
}
|
||||
153
lib/core/log.sh
Normal file
153
lib/core/log.sh
Normal file
@@ -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
|
||||
163
lib/core/sudo.sh
163
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"
|
||||
|
||||
156
lib/core/timeout.sh
Normal file
156
lib/core/timeout.sh
Normal file
@@ -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"
|
||||
}
|
||||
171
lib/core/ui.sh
Executable file
171
lib/core/ui.sh
Executable file
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user