From c99535c2d719589aedc35186dd4997b2f3439123 Mon Sep 17 00:00:00 2001 From: bikraj2 Date: Fri, 16 Jan 2026 19:28:32 +0530 Subject: [PATCH 01/91] fix(history): prevent adding multiple history entries with same path when scanning --- cmd/analyze/main.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index 59fe79a..7749d24 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -971,7 +971,9 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { } selected := m.entries[m.selected] if selected.IsDir { - m.history = append(m.history, snapshotFromModel(m)) + if len(m.history) == 0 || m.history[len(m.history)-1].Path != m.path { + m.history = append(m.history, snapshotFromModel(m)) + } m.path = selected.Path m.selected = 0 m.offset = 0 From 7a46aa04dbb72290e5e21ac1299a6d2f25c78f94 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 17 Jan 2026 09:08:41 +0800 Subject: [PATCH 02/91] security: harden BOM processing and LaunchAgents detection - Add path traversal protection in BOM receipt parsing - Remove invalid ~/Library/LaunchDaemons path references - Strengthen LaunchAgents matching (min 5 chars, exclude com.apple.*) - Add 300s timeout to brew cask uninstall to prevent hangs Addresses security review findings from V1.21.0 audit. --- lib/core/app_protection.sh | 231 ++++++++++++++++++++----------------- lib/uninstall/brew.sh | 43 ++++--- 2 files changed, 146 insertions(+), 128 deletions(-) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 90bfdb9..bdd0147 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -13,7 +13,7 @@ _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" [[ -z "${MOLE_BASE_LOADED:-}" ]] && source "$_MOLE_CORE_DIR/base.sh" # Declare WHITELIST_PATTERNS if not already set (used by is_path_whitelisted) -if ! declare -p WHITELIST_PATTERNS &> /dev/null; then +if ! declare -p WHITELIST_PATTERNS &>/dev/null; then declare -a WHITELIST_PATTERNS=() fi @@ -439,12 +439,12 @@ is_critical_system_component() { lower=$(echo "$token" | LC_ALL=C tr '[:upper:]' '[:lower:]') case "$lower" in - *backgroundtaskmanagement* | *loginitems* | *systempreferences* | *systemsettings* | *settings* | *preferences* | *controlcenter* | *biometrickit* | *sfl* | *tcc*) - return 0 - ;; - *) - return 1 - ;; + *backgroundtaskmanagement* | *loginitems* | *systempreferences* | *systemsettings* | *settings* | *preferences* | *controlcenter* | *biometrickit* | *sfl* | *tcc*) + return 0 + ;; + *) + return 1 + ;; esac } @@ -522,25 +522,25 @@ should_protect_path() { # 2. Protect caches critical for system UI rendering # These caches are essential for modern macOS (Sonoma/Sequoia) system UI rendering case "$path" in - # System Settings and Control Center caches (CRITICAL - prevents blank panel bug) - *com.apple.systempreferences.cache* | *com.apple.Settings.cache* | *com.apple.controlcenter.cache*) - return 0 - ;; - # Finder and Dock (system essential) - *com.apple.finder.cache* | *com.apple.dock.cache*) - return 0 - ;; - # System XPC services and sandboxed containers - */Library/Containers/com.apple.Settings* | */Library/Containers/com.apple.SystemSettings* | */Library/Containers/com.apple.controlcenter*) - return 0 - ;; - */Library/Group\ Containers/com.apple.systempreferences* | */Library/Group\ Containers/com.apple.Settings*) - return 0 - ;; - # Shared file lists for System Settings (macOS Sequoia) - Issue #136 - */com.apple.sharedfilelist/*com.apple.Settings* | */com.apple.sharedfilelist/*com.apple.SystemSettings* | */com.apple.sharedfilelist/*systempreferences*) - return 0 - ;; + # System Settings and Control Center caches (CRITICAL - prevents blank panel bug) + *com.apple.systempreferences.cache* | *com.apple.Settings.cache* | *com.apple.controlcenter.cache*) + return 0 + ;; + # Finder and Dock (system essential) + *com.apple.finder.cache* | *com.apple.dock.cache*) + return 0 + ;; + # System XPC services and sandboxed containers + */Library/Containers/com.apple.Settings* | */Library/Containers/com.apple.SystemSettings* | */Library/Containers/com.apple.controlcenter*) + return 0 + ;; + */Library/Group\ Containers/com.apple.systempreferences* | */Library/Group\ Containers/com.apple.Settings*) + return 0 + ;; + # Shared file lists for System Settings (macOS Sequoia) - Issue #136 + */com.apple.sharedfilelist/*com.apple.Settings* | */com.apple.sharedfilelist/*com.apple.SystemSettings* | */com.apple.sharedfilelist/*systempreferences*) + return 0 + ;; esac # 3. Extract bundle ID from sandbox paths @@ -555,24 +555,24 @@ should_protect_path() { # 4. Check for specific hardcoded critical patterns case "$path" in - *com.apple.Settings* | *com.apple.SystemSettings* | *com.apple.controlcenter* | *com.apple.finder* | *com.apple.dock*) - return 0 - ;; + *com.apple.Settings* | *com.apple.SystemSettings* | *com.apple.controlcenter* | *com.apple.finder* | *com.apple.dock*) + return 0 + ;; esac # 5. Protect critical preference files and user data case "$path" in - */Library/Preferences/com.apple.dock.plist | */Library/Preferences/com.apple.finder.plist) - return 0 - ;; - # Bluetooth and WiFi configurations - */ByHost/com.apple.bluetooth.* | */ByHost/com.apple.wifi.*) - return 0 - ;; - # iCloud Drive - protect user's cloud synced data - */Library/Mobile\ Documents* | */Mobile\ Documents*) - return 0 - ;; + */Library/Preferences/com.apple.dock.plist | */Library/Preferences/com.apple.finder.plist) + return 0 + ;; + # Bluetooth and WiFi configurations + */ByHost/com.apple.bluetooth.* | */ByHost/com.apple.wifi.*) + return 0 + ;; + # iCloud Drive - protect user's cloud synced data + */Library/Mobile\ Documents* | */Mobile\ Documents*) + return 0 + ;; esac # 6. Match full path against protected patterns @@ -611,9 +611,9 @@ is_path_whitelisted() { local check_pattern="${pattern%/}" local has_glob="false" case "$check_pattern" in - *\** | *\?* | *\[*) - has_glob="true" - ;; + *\** | *\?* | *\[*) + has_glob="true" + ;; esac # Check for exact match or glob pattern match @@ -643,6 +643,14 @@ is_path_whitelisted() { find_app_files() { local bundle_id="$1" local app_name="$2" + + # Early validation: require at least one valid identifier + # Skip scanning if both bundle_id and app_name are invalid + if [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && + [[ -z "$app_name" || ${#app_name} -lt 2 ]]; then + return 0 # Silent return to avoid invalid scanning + fi + local -a files_to_clean=() # Normalize app name for matching @@ -665,7 +673,6 @@ find_app_files() { "$HOME/Library/HTTPStorages/$bundle_id" "$HOME/Library/Cookies/$bundle_id.binarycookies" "$HOME/Library/LaunchAgents/$bundle_id.plist" - "$HOME/Library/LaunchDaemons/$bundle_id.plist" "$HOME/Library/Application Scripts/$bundle_id" "$HOME/Library/Services/$app_name.workflow" "$HOME/Library/QuickLook/$app_name.qlgenerator" @@ -709,17 +716,17 @@ find_app_files() { # Safety check: Skip if path ends with a common directory name (indicates empty app_name/bundle_id) # This prevents deletion of entire Library subdirectories when bundle_id is empty case "$expanded_path" in - */Library/Application\ Support | */Library/Application\ Support/ | \ - */Library/Caches | */Library/Caches/ | \ - */Library/Logs | */Library/Logs/ | \ - */Library/Containers | */Library/Containers/ | \ - */Library/WebKit | */Library/WebKit/ | \ - */Library/HTTPStorages | */Library/HTTPStorages/ | \ - */Library/Application\ Scripts | */Library/Application\ Scripts/ | \ - */Library/Autosave\ Information | */Library/Autosave\ Information/ | \ - */Library/Group\ Containers | */Library/Group\ Containers/) - continue - ;; + */Library/Application\ Support | */Library/Application\ Support/ | \ + */Library/Caches | */Library/Caches/ | \ + */Library/Logs | */Library/Logs/ | \ + */Library/Containers | */Library/Containers/ | \ + */Library/WebKit | */Library/WebKit/ | \ + */Library/HTTPStorages | */Library/HTTPStorages/ | \ + */Library/Application\ Scripts | */Library/Application\ Scripts/ | \ + */Library/Autosave\ Information | */Library/Autosave\ Information/ | \ + */Library/Group\ Containers | */Library/Group\ Containers/) + continue + ;; esac files_to_clean+=("$expanded_path") @@ -730,28 +737,26 @@ find_app_files() { [[ -f ~/Library/Preferences/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id.plist") [[ -d ~/Library/Preferences/ByHost ]] && while IFS= read -r -d '' pref; do files_to_clean+=("$pref") - done < <(command find ~/Library/Preferences/ByHost -maxdepth 1 \( -name "$bundle_id*.plist" \) -print0 2> /dev/null) + done < <(command find ~/Library/Preferences/ByHost -maxdepth 1 \( -name "$bundle_id*.plist" \) -print0 2>/dev/null) # Group Containers (special handling) if [[ -d ~/Library/Group\ Containers ]]; then while IFS= read -r -d '' container; do files_to_clean+=("$container") - done < <(command find ~/Library/Group\ Containers -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null) + done < <(command find ~/Library/Group\ Containers -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2>/dev/null) fi fi - # Launch Agents and Daemons by name (special handling) - if [[ ${#app_name} -gt 3 ]]; then - if [[ -d ~/Library/LaunchAgents ]]; then - while IFS= read -r -d '' plist; do - files_to_clean+=("$plist") - done < <(command find ~/Library/LaunchAgents -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null) - fi - if [[ -d ~/Library/LaunchDaemons ]]; then - while IFS= read -r -d '' plist; do - files_to_clean+=("$plist") - done < <(command find ~/Library/LaunchDaemons -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null) - fi + # Launch Agents by name (special handling) + # Note: LaunchDaemons are system-level and handled in find_app_system_files() + if [[ ${#app_name} -ge 5 ]] && [[ -d ~/Library/LaunchAgents ]]; then + while IFS= read -r -d '' plist; do + local plist_name=$(basename "$plist") + if [[ "$plist_name" =~ ^com\.apple\. ]]; then + continue + fi + files_to_clean+=("$plist") + done < <(command find ~/Library/LaunchAgents -maxdepth 1 -name "*$app_name*.plist" -print0 2>/dev/null) fi # Handle specialized toolchains and development environments @@ -764,10 +769,10 @@ find_app_files() { # 2. Android Studio (Google) if [[ "$app_name" =~ Android.*Studio|android.*studio ]] || [[ "$bundle_id" =~ google.*android.*studio|jetbrains.*android ]]; then - for d in ~/AndroidStudioProjects ~/Library/Android ~/.android ~/.gradle; do + for d in ~/AndroidStudioProjects ~/Library/Android ~/.android; do [[ -d "$d" ]] && files_to_clean+=("$d") done - [[ -d ~/Library/Application\ Support/Google ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find ~/Library/Application\ Support/Google -maxdepth 1 -name "AndroidStudio*" -print0 2> /dev/null) + [[ -d ~/Library/Application\ Support/Google ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find ~/Library/Application\ Support/Google -maxdepth 1 -name "AndroidStudio*" -print0 2>/dev/null) fi # 3. Xcode (Apple) @@ -779,7 +784,7 @@ find_app_files() { # 4. JetBrains (IDE settings) if [[ "$bundle_id" =~ jetbrains ]] || [[ "$app_name" =~ IntelliJ|PyCharm|WebStorm|GoLand|RubyMine|PhpStorm|CLion|DataGrip|Rider ]]; then for base in ~/Library/Application\ Support/JetBrains ~/Library/Caches/JetBrains ~/Library/Logs/JetBrains; do - [[ -d "$base" ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find "$base" -maxdepth 1 -name "${app_name}*" -print0 2> /dev/null) + [[ -d "$base" ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find "$base" -maxdepth 1 -name "${app_name}*" -print0 2>/dev/null) done fi @@ -846,11 +851,11 @@ find_app_system_files() { # Safety check: Skip if path ends with a common directory name (indicates empty app_name/bundle_id) case "$p" in - /Library/Application\ Support | /Library/Application\ Support/ | \ - /Library/Caches | /Library/Caches/ | \ - /Library/Logs | /Library/Logs/) - continue - ;; + /Library/Application\ Support | /Library/Application\ Support/ | \ + /Library/Caches | /Library/Caches/ | \ + /Library/Logs | /Library/Logs/) + continue + ;; esac system_files+=("$p") @@ -861,7 +866,7 @@ find_app_system_files() { for base in /Library/LaunchAgents /Library/LaunchDaemons; do [[ -d "$base" ]] && while IFS= read -r -d '' plist; do system_files+=("$plist") - done < <(command find "$base" -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null) + done < <(command find "$base" -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2>/dev/null) done fi @@ -870,11 +875,11 @@ find_app_system_files() { if [[ -n "$bundle_id" && "$bundle_id" != "unknown" && ${#bundle_id} -gt 3 ]]; then [[ -d /Library/PrivilegedHelperTools ]] && while IFS= read -r -d '' helper; do system_files+=("$helper") - done < <(command find /Library/PrivilegedHelperTools -maxdepth 1 \( -name "$bundle_id*" \) -print0 2> /dev/null) + done < <(command find /Library/PrivilegedHelperTools -maxdepth 1 \( -name "$bundle_id*" \) -print0 2>/dev/null) [[ -d /private/var/db/receipts ]] && while IFS= read -r -d '' receipt; do system_files+=("$receipt") - done < <(command find /private/var/db/receipts -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null) + done < <(command find /private/var/db/receipts -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2>/dev/null) fi local receipt_files="" @@ -904,6 +909,13 @@ find_app_receipt_files() { # Skip if no bundle ID [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0 + # Validate bundle_id format to prevent wildcard injection + # Only allow alphanumeric characters, dots, hyphens, and underscores + if [[ ! "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]]; then + debug_log "Invalid bundle_id format: $bundle_id" + return 0 + fi + local -a receipt_files=() local -a bom_files=() @@ -912,7 +924,7 @@ find_app_receipt_files() { if [[ -d /private/var/db/receipts ]]; then while IFS= read -r -d '' bom; do bom_files+=("$bom") - done < <(find /private/var/db/receipts -maxdepth 1 -name "${bundle_id}*.bom" -print0 2> /dev/null) + done < <(find /private/var/db/receipts -maxdepth 1 -name "${bundle_id}*.bom" -print0 2>/dev/null) fi # Process bom files if any found @@ -924,7 +936,7 @@ find_app_receipt_files() { # lsbom -f: file paths only # -s: suppress output (convert to text) local bom_content - bom_content=$(lsbom -f -s "$bom_file" 2> /dev/null) + bom_content=$(lsbom -f -s "$bom_file" 2>/dev/null) while IFS= read -r file_path; do # Standardize path (remove leading dot) @@ -935,6 +947,15 @@ find_app_receipt_files() { clean_path="/$clean_path" fi + # Path traversal protection: reject paths containing .. + if [[ "$clean_path" =~ \.\. ]]; then + debug_log "Rejected path traversal in BOM: $clean_path" + continue + fi + + # Normalize path (remove duplicate slashes) + clean_path=$(echo "$clean_path" | sed 's#//*#/#g') + # ------------------------------------------------------------------------ # Safety check: restrict removal to trusted paths # ------------------------------------------------------------------------ @@ -942,21 +963,21 @@ find_app_receipt_files() { # Whitelisted prefixes (exclude /Users, /usr, /opt) case "$clean_path" in - /Applications/*) is_safe=true ;; - /Library/Application\ Support/*) is_safe=true ;; - /Library/Caches/*) is_safe=true ;; - /Library/Logs/*) is_safe=true ;; - /Library/Preferences/*) is_safe=true ;; - /Library/LaunchAgents/*) is_safe=true ;; - /Library/LaunchDaemons/*) is_safe=true ;; - /Library/PrivilegedHelperTools/*) is_safe=true ;; - /Library/Extensions/*) is_safe=false ;; - *) is_safe=false ;; + /Applications/*) is_safe=true ;; + /Library/Application\ Support/*) is_safe=true ;; + /Library/Caches/*) is_safe=true ;; + /Library/Logs/*) is_safe=true ;; + /Library/Preferences/*) is_safe=true ;; + /Library/LaunchAgents/*) is_safe=true ;; + /Library/LaunchDaemons/*) is_safe=true ;; + /Library/PrivilegedHelperTools/*) is_safe=true ;; + /Library/Extensions/*) is_safe=false ;; + *) is_safe=false ;; esac # Hard blocks case "$clean_path" in - /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/*) is_safe=false ;; + /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/*) is_safe=false ;; esac if [[ "$is_safe" == "true" && -e "$clean_path" ]]; then @@ -965,7 +986,7 @@ find_app_receipt_files() { continue fi - if declare -f should_protect_path > /dev/null 2>&1; then + if declare -f should_protect_path >/dev/null 2>&1; then if should_protect_path "$clean_path"; then continue fi @@ -974,7 +995,7 @@ find_app_receipt_files() { receipt_files+=("$clean_path") fi - done <<< "$bom_content" + done <<<"$bom_content" done fi if [[ ${#receipt_files[@]} -gt 0 ]]; then @@ -991,34 +1012,34 @@ force_kill_app() { # 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 "") + 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 + 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 + 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 + 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 + 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 + 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 @@ -1026,7 +1047,7 @@ force_kill_app() { # 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 + if ! pgrep -x "$match_pattern" >/dev/null 2>&1; then return 0 fi sleep 1 @@ -1034,7 +1055,7 @@ force_kill_app() { done # Still running after all attempts - pgrep -x "$match_pattern" > /dev/null 2>&1 && return 1 || return 0 + pgrep -x "$match_pattern" >/dev/null 2>&1 && return 1 || return 0 } # Note: calculate_total_size() is defined in lib/core/file_ops.sh diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index 569a2dd..d7f2d01 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -18,13 +18,13 @@ resolve_path() { [[ -e "$p" ]] || return 1 # macOS 12.3+ and Linux have realpath - if realpath "$p" 2> /dev/null; then + if realpath "$p" 2>/dev/null; then return 0 fi # Fallback: use cd -P to resolve directory, then append basename local dir base - dir=$(cd -P "$(dirname "$p")" 2> /dev/null && pwd) || return 1 + dir=$(cd -P "$(dirname "$p")" 2>/dev/null && pwd) || return 1 base=$(basename "$p") echo "$dir/$base" } @@ -32,7 +32,7 @@ resolve_path() { # Check if Homebrew is installed and accessible # Returns: 0 if brew is available, 1 otherwise is_homebrew_available() { - command -v brew > /dev/null 2>&1 + command -v brew >/dev/null 2>&1 } # Extract cask token from a Caskroom path @@ -44,8 +44,8 @@ _extract_cask_token_from_path() { # Check if path is inside Caskroom case "$path" in - /opt/homebrew/Caskroom/* | /usr/local/Caskroom/*) ;; - *) return 1 ;; + /opt/homebrew/Caskroom/* | /usr/local/Caskroom/*) ;; + *) return 1 ;; esac # Extract token from path: /opt/homebrew/Caskroom///... @@ -87,9 +87,9 @@ _detect_cask_via_caskroom_search() { [[ -d "$room" ]] || continue while IFS= read -r match; do [[ -n "$match" ]] || continue - token=$(_extract_cask_token_from_path "$match" 2> /dev/null) || continue + token=$(_extract_cask_token_from_path "$match" 2>/dev/null) || continue [[ -n "$token" ]] && tokens+=("$token") - done < <(find "$room" -maxdepth 3 -name "$app_bundle_name" 2> /dev/null) + done < <(find "$room" -maxdepth 3 -name "$app_bundle_name" 2>/dev/null) done # Need at least one token @@ -101,7 +101,7 @@ _detect_cask_via_caskroom_search() { # Only succeed if exactly one unique token found and it's installed if ((${#uniq[@]} == 1)) && [[ -n "${uniq[0]}" ]]; then - HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "${uniq[0]}" || return 1 + HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2>/dev/null | grep -qxF "${uniq[0]}" || return 1 echo "${uniq[0]}" return 0 fi @@ -115,7 +115,7 @@ _detect_cask_via_symlink_check() { [[ -L "$app_path" ]] || return 1 local target - target=$(readlink "$app_path" 2> /dev/null) || return 1 + target=$(readlink "$app_path" 2>/dev/null) || return 1 _extract_cask_token_from_path "$target" } @@ -127,10 +127,10 @@ _detect_cask_via_brew_list() { app_name_lower=$(echo "${app_bundle_name%.app}" | LC_ALL=C tr '[:upper:]' '[:lower:]') local cask_name - cask_name=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -Fix "$app_name_lower") || return 1 + cask_name=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2>/dev/null | grep -Fix "$app_name_lower") || return 1 # Verify this cask actually owns this app path - HOMEBREW_NO_ENV_HINTS=1 brew info --cask "$cask_name" 2> /dev/null | grep -qF "$app_path" || return 1 + HOMEBREW_NO_ENV_HINTS=1 brew info --cask "$cask_name" 2>/dev/null | grep -qF "$app_path" || return 1 echo "$cask_name" } @@ -173,30 +173,27 @@ brew_uninstall_cask() { debug_log "Attempting brew uninstall --cask $cask_name" - # Run uninstall with timeout (suppress hints/auto-update) - debug_log "Attempting brew uninstall --cask $cask_name" - # Ensure we have sudo access if needed, to prevent brew from hanging on password prompt - # Many brew casks need sudo to uninstall - if ! sudo -n true 2> /dev/null; then - # If we don't have sudo, try to get it (visibly) + if ! sudo -n true 2>/dev/null; then sudo -v fi local uninstall_ok=false + local brew_exit=0 - # Run directly without output capture to allow user interaction/visibility - # This avoids silence/hangs when brew asks for passwords or confirmation - if HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ - brew uninstall --cask "$cask_name"; then + # Run with timeout to prevent hangs from problematic cask scripts + if run_with_timeout 300 \ + env HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + brew uninstall --cask "$cask_name" 2>&1; then uninstall_ok=true else - debug_log "brew uninstall failed with exit code $?" + brew_exit=$? + debug_log "brew uninstall timeout or failed with exit code: $brew_exit" fi # Verify removal local cask_gone=true app_gone=true - HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "$cask_name" && cask_gone=false + HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2>/dev/null | grep -qxF "$cask_name" && cask_gone=false [[ -n "$app_path" && -e "$app_path" ]] && app_gone=false # Success: uninstall worked and both are gone, or already uninstalled From 51abd890bc716ba585b911a5019e6408d7466275 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 17 Jan 2026 09:09:02 +0800 Subject: [PATCH 03/91] fix: improve Dock cleanup path handling - Add control character validation - Use canonical paths (pwd -P) for better resolution - Strengthen fallback logic for deleted applications - Add empty path guards --- lib/core/common.sh | 59 +++++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/lib/core/common.sh b/lib/core/common.sh index a507aa2..99e66ca 100755 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -43,9 +43,9 @@ update_via_homebrew() { echo "Updating Homebrew..." fi - brew update > "$temp_update" 2>&1 & + brew update >"$temp_update" 2>&1 & local update_pid=$! - wait $update_pid 2> /dev/null || true # Continue even if brew update fails + wait $update_pid 2>/dev/null || true # Continue even if brew update fails if [[ -t 1 ]]; then stop_inline_spinner @@ -58,9 +58,9 @@ update_via_homebrew() { echo "Upgrading Mole..." fi - brew upgrade mole > "$temp_upgrade" 2>&1 & + brew upgrade mole >"$temp_upgrade" 2>&1 & local upgrade_pid=$! - wait $upgrade_pid 2> /dev/null || true # Continue even if brew upgrade fails + wait $upgrade_pid 2>/dev/null || true # Continue even if brew upgrade fails local upgrade_output upgrade_output=$(cat "$temp_upgrade") @@ -78,7 +78,7 @@ update_via_homebrew() { if echo "$upgrade_output" | grep -q "already installed"; then local installed_version - installed_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') + 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 "" @@ -89,14 +89,14 @@ update_via_homebrew() { else echo "$upgrade_output" | grep -Ev "^(==>|Updating Homebrew|Warning:)" || true local new_version - new_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') + new_version=$(brew list --versions mole 2>/dev/null | awk '{print $2}') echo "" echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-$current_version})" echo "" fi # Clear update cache (suppress errors if cache doesn't exist or is locked) - rm -f "$HOME/.cache/mole/version_check" "$HOME/.cache/mole/update_message" 2> /dev/null || true + rm -f "$HOME/.cache/mole/version_check" "$HOME/.cache/mole/update_message" 2>/dev/null || true } # Remove applications from Dock @@ -125,35 +125,40 @@ remove_apps_from_dock() { local changed=false for target in "${targets[@]}"; do local app_path="$target" - # Normalize path for comparison - use original path if app already deleted - local full_path - if full_path=$(cd "$(dirname "$app_path")" 2> /dev/null && pwd); then - full_path="$full_path/$(basename "$app_path")" - else - # App already deleted - use the original path as-is - # Remove ~/ prefix and expand to full path if needed - if [[ "$app_path" == ~/* ]]; then - full_path="$HOME/${app_path#~/}" - elif [[ "$app_path" != /* ]]; then - # Relative path - skip this entry - continue - else - full_path="$app_path" - fi + local full_path="" + + if [[ "$app_path" =~ [[:cntrl:]] ]]; then + debug_log "Skipping dock removal for path with control chars: $app_path" + continue fi - # URL-encode the path for matching against Dock URLs (spaces -> %20) + if [[ -e "$app_path" ]]; then + if full_path=$(cd "$(dirname "$app_path")" 2>/dev/null && pwd -P); then + full_path="$full_path/$(basename "$app_path")" + else + continue + fi + else + case "$app_path" in + ~/*) full_path="$HOME/${app_path#~/}" ;; + /*) full_path="$app_path" ;; + *) continue ;; + esac + fi + + [[ -z "$full_path" ]] && continue + local encoded_path="${full_path// /%20}" # Find the index of the app in persistent-apps local i=0 while true; do local label - label=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-label" "$plist" 2> /dev/null || echo "") + label=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-label" "$plist" 2>/dev/null || echo "") [[ -z "$label" ]] && break local url - url=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-data:_CFURLString" "$plist" 2> /dev/null || echo "") + url=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-data:_CFURLString" "$plist" 2>/dev/null || echo "") [[ -z "$url" ]] && { ((i++)) continue @@ -161,7 +166,7 @@ remove_apps_from_dock() { # Match by URL-encoded path to handle spaces in app names if [[ -n "$encoded_path" && "$url" == *"$encoded_path"* ]]; then - if /usr/libexec/PlistBuddy -c "Delete :persistent-apps:$i" "$plist" 2> /dev/null; then + if /usr/libexec/PlistBuddy -c "Delete :persistent-apps:$i" "$plist" 2>/dev/null; then changed=true # After deletion, current index i now points to the next item continue @@ -173,6 +178,6 @@ remove_apps_from_dock() { if [[ "$changed" == "true" ]]; then # Restart Dock to apply changes from the plist - killall Dock 2> /dev/null || true + killall Dock 2>/dev/null || true fi } From 12cacaa6ccb012a0b53eb0efc236fcffe3580aef Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 17 Jan 2026 09:09:11 +0800 Subject: [PATCH 04/91] refactor: enhance pattern detection and symlink safety - Expand sensitive data patterns (credentials, cloud configs, media folders) - Add symlink target validation in path deletion checks - Remove shared Gradle cache from Android Studio cleanup --- lib/core/file_ops.sh | 109 +++++++++++++++++++-------------- lib/uninstall/batch.sh | 134 ++++++++++++++++++++++++----------------- 2 files changed, 144 insertions(+), 99 deletions(-) diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 9fdd65f..7909cef 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -39,6 +39,25 @@ validate_path_for_deletion() { return 1 fi + # Check symlink target if path is a symbolic link + if [[ -L "$path" ]]; then + local link_target + link_target=$(readlink "$path" 2>/dev/null) || { + log_error "Cannot read symlink: $path" + return 1 + } + + # If symlink points to absolute path, validate target + if [[ "$link_target" == /* ]]; then + case "$link_target" in + /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) + log_error "Symlink points to protected system path: $path -> $link_target" + return 1 + ;; + esac + fi + fi + # Check path is absolute if [[ "$path" != /* ]]; then log_error "Path validation failed: path must be absolute: $path" @@ -61,47 +80,47 @@ validate_path_for_deletion() { # Allow deletion of coresymbolicationd cache (safe system cache that can be rebuilt) case "$path" in - /System/Library/Caches/com.apple.coresymbolicationd/data | /System/Library/Caches/com.apple.coresymbolicationd/data/*) - return 0 - ;; + /System/Library/Caches/com.apple.coresymbolicationd/data | /System/Library/Caches/com.apple.coresymbolicationd/data/*) + return 0 + ;; esac # Allow known safe paths under /private case "$path" in - /private/tmp | /private/tmp/* | \ - /private/var/tmp | /private/var/tmp/* | \ - /private/var/log | /private/var/log/* | \ - /private/var/folders | /private/var/folders/* | \ - /private/var/db/diagnostics | /private/var/db/diagnostics/* | \ - /private/var/db/DiagnosticPipeline | /private/var/db/DiagnosticPipeline/* | \ - /private/var/db/powerlog | /private/var/db/powerlog/* | \ - /private/var/db/reportmemoryexception | /private/var/db/reportmemoryexception/*) - return 0 - ;; + /private/tmp | /private/tmp/* | \ + /private/var/tmp | /private/var/tmp/* | \ + /private/var/log | /private/var/log/* | \ + /private/var/folders | /private/var/folders/* | \ + /private/var/db/diagnostics | /private/var/db/diagnostics/* | \ + /private/var/db/DiagnosticPipeline | /private/var/db/DiagnosticPipeline/* | \ + /private/var/db/powerlog | /private/var/db/powerlog/* | \ + /private/var/db/reportmemoryexception | /private/var/db/reportmemoryexception/*) + return 0 + ;; esac # Check path isn't critical system directory case "$path" in - / | /bin | /bin/* | /sbin | /sbin/* | /usr | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /usr/lib | /usr/lib/* | /System | /System/* | /Library/Extensions) - log_error "Path validation failed: critical system directory: $path" - return 1 - ;; - /private) - log_error "Path validation failed: critical system directory: $path" - return 1 - ;; - /etc | /etc/* | /private/etc | /private/etc/*) - log_error "Path validation failed: /etc contains critical system files: $path" - return 1 - ;; - /var | /var/db | /var/db/* | /private/var | /private/var/db | /private/var/db/*) - log_error "Path validation failed: /var/db contains system databases: $path" - return 1 - ;; + / | /bin | /bin/* | /sbin | /sbin/* | /usr | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /usr/lib | /usr/lib/* | /System | /System/* | /Library/Extensions) + log_error "Path validation failed: critical system directory: $path" + return 1 + ;; + /private) + log_error "Path validation failed: critical system directory: $path" + return 1 + ;; + /etc | /etc/* | /private/etc | /private/etc/*) + log_error "Path validation failed: /etc contains critical system files: $path" + return 1 + ;; + /var | /var/db | /var/db/* | /private/var | /private/var/db | /private/var/db/*) + log_error "Path validation failed: /var/db contains system databases: $path" + return 1 + ;; esac # Check if path is protected (keychains, system settings, etc) - if declare -f should_protect_path > /dev/null 2>&1; then + if declare -f should_protect_path >/dev/null 2>&1; then if should_protect_path "$path"; then if [[ "${MO_DEBUG:-0}" == "1" ]]; then log_warning "Path validation: protected path skipped: $path" @@ -144,16 +163,16 @@ safe_remove() { if [[ -e "$path" ]]; then local size_kb - size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0") + size_kb=$(get_path_size_kb "$path" 2>/dev/null || echo "0") if [[ "$size_kb" -gt 0 ]]; then file_size=$(bytes_to_human "$((size_kb * 1024))") fi if [[ -f "$path" || -d "$path" ]] && ! [[ -L "$path" ]]; then local mod_time - mod_time=$(stat -f%m "$path" 2> /dev/null || echo "0") + mod_time=$(stat -f%m "$path" 2>/dev/null || echo "0") local now - now=$(date +%s 2> /dev/null || echo "0") + now=$(date +%s 2>/dev/null || echo "0") if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then file_age=$(((now - mod_time) / 86400)) fi @@ -221,18 +240,18 @@ safe_sudo_remove() { local file_size="" local file_age="" - if sudo test -e "$path" 2> /dev/null; then + if sudo test -e "$path" 2>/dev/null; then local size_kb - size_kb=$(sudo du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0") + size_kb=$(sudo du -sk "$path" 2>/dev/null | awk '{print $1}' || echo "0") if [[ "$size_kb" -gt 0 ]]; then file_size=$(bytes_to_human "$((size_kb * 1024))") fi - if sudo test -f "$path" 2> /dev/null || sudo test -d "$path" 2> /dev/null; then + if sudo test -f "$path" 2>/dev/null || sudo test -d "$path" 2>/dev/null; then local mod_time - mod_time=$(sudo stat -f%m "$path" 2> /dev/null || echo "0") + mod_time=$(sudo stat -f%m "$path" 2>/dev/null || echo "0") local now - now=$(date +%s 2> /dev/null || echo "0") + now=$(date +%s 2>/dev/null || echo "0") if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then file_age=$(((now - mod_time) / 86400)) fi @@ -249,7 +268,7 @@ safe_sudo_remove() { debug_log "Removing (sudo): $path" # Perform the deletion - if sudo rm -rf "$path" 2> /dev/null; then # SAFE: safe_sudo_remove implementation + if sudo rm -rf "$path" 2>/dev/null; then # SAFE: safe_sudo_remove implementation return 0 else log_error "Failed to remove (sudo): $path" @@ -298,7 +317,7 @@ safe_find_delete() { continue fi safe_remove "$match" true || true - done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) + done < <(command find "$base_dir" "${find_args[@]}" -print0 2>/dev/null || true) return 0 } @@ -311,12 +330,12 @@ safe_sudo_find_delete() { local type_filter="${4:-f}" # Validate base directory (use sudo for permission-restricted dirs) - if ! sudo test -d "$base_dir" 2> /dev/null; then + if ! sudo test -d "$base_dir" 2>/dev/null; then debug_log "Directory does not exist (skipping): $base_dir" return 0 fi - if sudo test -L "$base_dir" 2> /dev/null; then + if sudo test -L "$base_dir" 2>/dev/null; then log_error "Refusing to search symlinked directory: $base_dir" return 1 fi @@ -340,7 +359,7 @@ safe_sudo_find_delete() { continue fi safe_sudo_remove "$match" || true - done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) + done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2>/dev/null || true) return 0 } @@ -360,7 +379,7 @@ get_path_size_kb() { # Use || echo 0 to ensure failure in du (e.g. permission error) doesn't exit script under set -e # Pipefail would normally cause the pipeline to fail if du fails, but || handle catches it. local size - size=$(command du -sk "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true) + size=$(command du -sk "$path" 2>/dev/null | awk 'NR==1 {print $1; exit}' || true) # Ensure size is a valid number (fix for non-numeric du output) if [[ "$size" =~ ^[0-9]+$ ]]; then @@ -381,7 +400,7 @@ calculate_total_size() { size_kb=$(get_path_size_kb "$file") ((total_kb += size_kb)) fi - done <<< "$files" + done <<<"$files" echo "$total_kb" } diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index a34aa23..9d558bd 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -30,6 +30,32 @@ SENSITIVE_DATA_REGEX=$( echo "${SENSITIVE_DATA_PATTERNS[*]}" ) +# High-performance sensitive data detection (pure Bash, no subprocess) +# Faster than grep for batch operations, especially when processing many apps +has_sensitive_data() { + local files="$1" + [[ -z "$files" ]] && return 1 + + while IFS= read -r file; do + [[ -z "$file" ]] && continue + + # Use Bash native pattern matching (faster than spawning grep) + case "$file" in + */.warp* | */.config/* | */themes/* | */settings/* | */User\ Data/* | \ + */.ssh/* | */.gnupg/* | */Documents/* | */Preferences/*.plist | \ + */Desktop/* | */Downloads/* | */Movies/* | */Music/* | */Pictures/* | \ + */.password* | */.token* | */.auth* | */keychain* | \ + */Passwords/* | */Accounts/* | */Cookies/* | \ + */.aws/* | */.docker/config.json | */.kube/* | \ + */credentials/* | */secrets/*) + return 0 # Found sensitive data + ;; + esac + done <<<"$files" + + return 1 # Not found +} + # Decode and validate base64 file list (safe for set -e). decode_file_list() { local encoded="$1" @@ -37,8 +63,8 @@ decode_file_list() { local decoded # macOS uses -D, GNU uses -d. Always return 0 for set -e safety. - if ! decoded=$(printf '%s' "$encoded" | base64 -D 2> /dev/null); then - if ! decoded=$(printf '%s' "$encoded" | base64 -d 2> /dev/null); then + if ! decoded=$(printf '%s' "$encoded" | base64 -D 2>/dev/null); then + if ! decoded=$(printf '%s' "$encoded" | base64 -d 2>/dev/null); then log_error "Failed to decode file list for $app_name" >&2 echo "" return 0 # Return success with empty string @@ -57,7 +83,7 @@ decode_file_list() { echo "" return 0 # Return success with empty string fi - done <<< "$decoded" + done <<<"$decoded" echo "$decoded" return 0 @@ -73,20 +99,20 @@ stop_launch_services() { if [[ -d ~/Library/LaunchAgents ]]; then while IFS= read -r -d '' plist; do - launchctl unload "$plist" 2> /dev/null || true - done < <(find ~/Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) + launchctl unload "$plist" 2>/dev/null || true + done < <(find ~/Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2>/dev/null) fi if [[ "$has_system_files" == "true" ]]; then if [[ -d /Library/LaunchAgents ]]; then while IFS= read -r -d '' plist; do - sudo launchctl unload "$plist" 2> /dev/null || true - done < <(find /Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) + sudo launchctl unload "$plist" 2>/dev/null || true + done < <(find /Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2>/dev/null) fi if [[ -d /Library/LaunchDaemons ]]; then while IFS= read -r -d '' plist; do - sudo launchctl unload "$plist" 2> /dev/null || true - done < <(find /Library/LaunchDaemons -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) + sudo launchctl unload "$plist" 2>/dev/null || true + done < <(find /Library/LaunchDaemons -maxdepth 1 -name "${bundle_id}*.plist" -print0 2>/dev/null) fi fi } @@ -108,7 +134,7 @@ remove_login_item() { local escaped_name="${clean_name//\\/\\\\}" escaped_name="${escaped_name//\"/\\\"}" - osascript <<- EOF > /dev/null 2>&1 || true + osascript <<-EOF >/dev/null 2>&1 || true tell application "System Events" try set itemCount to count of login items @@ -142,9 +168,9 @@ remove_file_list() { if [[ -L "$file" ]]; then if [[ "$use_sudo" == "true" ]]; then - sudo rm "$file" 2> /dev/null && ((++count)) || true + sudo rm "$file" 2>/dev/null && ((++count)) || true else - rm "$file" 2> /dev/null && ((++count)) || true + rm "$file" 2>/dev/null && ((++count)) || true fi else if [[ "$use_sudo" == "true" ]]; then @@ -153,7 +179,7 @@ remove_file_list() { safe_remove "$file" true && ((++count)) || true fi fi - done <<< "$file_list" + done <<<"$file_list" echo "$count" } @@ -177,15 +203,15 @@ batch_uninstall_applications() { if [[ -t 1 ]]; then start_inline_spinner "Scanning files..."; fi for selected_app in "${selected_apps[@]}"; do [[ -z "$selected_app" ]] && continue - IFS='|' read -r _ app_path app_name bundle_id _ _ <<< "$selected_app" + IFS='|' read -r _ app_path app_name bundle_id _ _ <<<"$selected_app" # Check running app by bundle executable if available. local exec_name="" if [[ -e "$app_path/Contents/Info.plist" ]]; then - exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "") + exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2>/dev/null || echo "") fi local check_pattern="${exec_name:-$app_name}" - if pgrep -x "$check_pattern" > /dev/null 2>&1; then + if pgrep -x "$check_pattern" >/dev/null 2>&1; then running_apps+=("$app_name") fi @@ -230,7 +256,7 @@ batch_uninstall_applications() { # Check for sensitive user data once. local has_sensitive_data="false" - if [[ -n "$related_files" ]] && echo "$related_files" | grep -qE "$SENSITIVE_DATA_REGEX"; then + if has_sensitive_data "$related_files"; then has_sensitive_data="true" fi @@ -252,7 +278,7 @@ batch_uninstall_applications() { # Warn if user data is detected. local has_user_data=false for detail in "${app_details[@]}"; do - IFS='|' read -r _ _ _ _ _ _ has_sensitive_data <<< "$detail" + IFS='|' read -r _ _ _ _ _ _ has_sensitive_data <<<"$detail" if [[ "$has_sensitive_data" == "true" ]]; then has_user_data=true break @@ -265,7 +291,7 @@ batch_uninstall_applications() { fi for detail in "${app_details[@]}"; do - IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag is_brew_cask cask_name <<< "$detail" + IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag is_brew_cask cask_name <<<"$detail" local app_size_display=$(bytes_to_human "$((total_kb * 1024))") local brew_tag="" @@ -288,7 +314,7 @@ batch_uninstall_applications() { fi ((file_count++)) fi - done <<< "$related_files" + done <<<"$related_files" # Show system files (limit to 5). local sys_file_count=0 @@ -299,7 +325,7 @@ batch_uninstall_applications() { fi ((sys_file_count++)) fi - done <<< "$system_files" + done <<<"$system_files" local total_hidden=$((file_count > max_files ? file_count - max_files : 0)) ((total_hidden += sys_file_count > max_files ? sys_file_count - max_files : 0)) @@ -325,24 +351,24 @@ batch_uninstall_applications() { IFS= read -r -s -n1 key || key="" drain_pending_input # Clean up any escape sequence remnants case "$key" in - $'\e' | q | Q) - echo "" - echo "" - return 0 - ;; - "" | $'\n' | $'\r' | y | Y) - echo "" # Move to next line - ;; - *) - echo "" - echo "" - return 0 - ;; + $'\e' | q | Q) + echo "" + echo "" + return 0 + ;; + "" | $'\n' | $'\r' | y | Y) + echo "" # Move to next line + ;; + *) + echo "" + echo "" + return 0 + ;; esac # Request sudo if needed. if [[ ${#sudo_apps[@]} -gt 0 ]]; then - if ! sudo -n true 2> /dev/null; then + if ! sudo -n true 2>/dev/null; then if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then echo "" log_error "Admin access denied" @@ -352,12 +378,12 @@ batch_uninstall_applications() { # Keep sudo alive during uninstall. parent_pid=$$ (while true; do - if ! kill -0 "$parent_pid" 2> /dev/null; then + if ! kill -0 "$parent_pid" 2>/dev/null; then exit 0 fi sudo -n true sleep 60 - done 2> /dev/null) & + done 2>/dev/null) & sudo_keepalive_pid=$! fi @@ -369,7 +395,7 @@ batch_uninstall_applications() { local current_index=0 for detail in "${app_details[@]}"; do ((current_index++)) - IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name <<< "$detail" + IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name <<<"$detail" local related_files=$(decode_file_list "$encoded_files" "$app_name") local system_files=$(decode_file_list "$encoded_system_files" "$app_name") local reason="" @@ -432,24 +458,24 @@ batch_uninstall_applications() { # Remove related files if app removal succeeded. if [[ -z "$reason" ]]; then - remove_file_list "$related_files" "false" > /dev/null + remove_file_list "$related_files" "false" >/dev/null # If brew successfully uninstalled the cask, avoid deleting # system-level files Mole discovered. Brew manages its own # receipts/symlinks and we don't want to fight it. if [[ "$used_brew_successfully" != "true" ]]; then - remove_file_list "$system_files" "true" > /dev/null + remove_file_list "$system_files" "true" >/dev/null fi # Clean up macOS defaults (preference domains). if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then - if defaults read "$bundle_id" &> /dev/null; then - defaults delete "$bundle_id" 2> /dev/null || true + if defaults read "$bundle_id" &>/dev/null; then + defaults delete "$bundle_id" 2>/dev/null || true fi # ByHost preferences (machine-specific). if [[ -d ~/Library/Preferences/ByHost ]]; then - find ~/Library/Preferences/ByHost -maxdepth 1 -name "${bundle_id}.*.plist" -delete 2> /dev/null || true + find ~/Library/Preferences/ByHost -maxdepth 1 -name "${bundle_id}.*.plist" -delete 2>/dev/null || true fi fi @@ -547,11 +573,11 @@ batch_uninstall_applications() { if [[ $failed_count -eq 1 ]]; then local first_reason=${failed_items[0]#*:} case "$first_reason" in - still*running*) reason_summary="is still running" ;; - remove*failed*) reason_summary="could not be removed" ;; - permission*denied*) reason_summary="permission denied" ;; - owned*by*) reason_summary="$first_reason (try with sudo)" ;; - *) reason_summary="$first_reason" ;; + still*running*) reason_summary="is still running" ;; + remove*failed*) reason_summary="could not be removed" ;; + permission*denied*) reason_summary="permission denied" ;; + owned*by*) reason_summary="$first_reason (try with sudo)" ;; + *) reason_summary="$first_reason" ;; esac fi summary_details+=("Failed: ${RED}${failed_list}${NC} ${reason_summary}") @@ -579,7 +605,7 @@ batch_uninstall_applications() { fi local autoremove_output removed_count - autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2> /dev/null) || true + autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2>/dev/null) || true removed_count=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" || true) removed_count=${removed_count:-0} @@ -597,7 +623,7 @@ batch_uninstall_applications() { if [[ $success_count -gt 0 ]]; then local -a removed_paths=() for detail in "${app_details[@]}"; do - IFS='|' read -r app_name app_path _ _ _ _ <<< "$detail" + IFS='|' read -r app_name app_path _ _ _ _ <<<"$detail" for success_name in "${success_items[@]}"; do if [[ "$success_name" == "$app_name" ]]; then removed_paths+=("$app_path") @@ -606,21 +632,21 @@ batch_uninstall_applications() { done done if [[ ${#removed_paths[@]} -gt 0 ]]; then - remove_apps_from_dock "${removed_paths[@]}" 2> /dev/null || true + remove_apps_from_dock "${removed_paths[@]}" 2>/dev/null || true fi fi # Clean up sudo keepalive if it was started. if [[ -n "${sudo_keepalive_pid:-}" ]]; then - kill "$sudo_keepalive_pid" 2> /dev/null || true - wait "$sudo_keepalive_pid" 2> /dev/null || true + kill "$sudo_keepalive_pid" 2>/dev/null || true + wait "$sudo_keepalive_pid" 2>/dev/null || true sudo_keepalive_pid="" fi # Invalidate cache if any apps were successfully uninstalled. if [[ $success_count -gt 0 ]]; then local cache_file="$HOME/.cache/mole/app_scan_cache" - rm -f "$cache_file" 2> /dev/null || true + rm -f "$cache_file" 2>/dev/null || true fi ((total_size_cleaned += total_size_freed)) From 060c48c48dea6fdeb974c787207c2d88dfffcfea Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 17 Jan 2026 09:49:42 +0800 Subject: [PATCH 05/91] refactor: enhance uninstall safety and fix dock removal - Add symlink/bundle_id/BOM validation to prevent injection attacks - Fix Dock removal for /Applications symlink (use pwd not pwd -P) - Fix brew uninstall test hanging (skip sudo in non-interactive mode) - Remove unused SENSITIVE_DATA_REGEX --- .gitignore | 1 + lib/core/app_protection.sh | 2 ++ lib/core/common.sh | 2 +- lib/core/file_ops.sh | 16 ++++++++++++---- lib/uninstall/batch.sh | 19 ------------------- lib/uninstall/brew.sh | 11 ++++++----- tests/uninstall.bats | 4 ---- 7 files changed, 22 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 2c2eb10..a00769c 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ tests/tmp-* CLAUDE.md GEMINI.md ANTIGRAVITY.md +WARP.md .cursorrules # Go build artifacts (development) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index bdd0147..05955cd 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -749,6 +749,8 @@ find_app_files() { # Launch Agents by name (special handling) # Note: LaunchDaemons are system-level and handled in find_app_system_files() + # Minimum 5-char threshold prevents false positives (e.g., "Time" matching system agents) + # Short-name apps (e.g., Zoom, Arc) are still cleaned via bundle_id matching above if [[ ${#app_name} -ge 5 ]] && [[ -d ~/Library/LaunchAgents ]]; then while IFS= read -r -d '' plist; do local plist_name=$(basename "$plist") diff --git a/lib/core/common.sh b/lib/core/common.sh index 99e66ca..ed3a225 100755 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -133,7 +133,7 @@ remove_apps_from_dock() { fi if [[ -e "$app_path" ]]; then - if full_path=$(cd "$(dirname "$app_path")" 2>/dev/null && pwd -P); then + if full_path=$(cd "$(dirname "$app_path")" 2>/dev/null && pwd); then full_path="$full_path/$(basename "$app_path")" else continue diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 7909cef..cc6ca8e 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -47,11 +47,19 @@ validate_path_for_deletion() { return 1 } - # If symlink points to absolute path, validate target - if [[ "$link_target" == /* ]]; then - case "$link_target" in + # Resolve relative symlinks to absolute paths for validation + local resolved_target="$link_target" + if [[ "$link_target" != /* ]]; then + local link_dir + link_dir=$(dirname "$path") + resolved_target=$(cd "$link_dir" 2>/dev/null && cd "$(dirname "$link_target")" 2>/dev/null && pwd)/$(basename "$link_target") || resolved_target="" + fi + + # Validate resolved target against protected paths + if [[ -n "$resolved_target" ]]; then + case "$resolved_target" in /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) - log_error "Symlink points to protected system path: $path -> $link_target" + log_error "Symlink points to protected system path: $path -> $resolved_target" return 1 ;; esac diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 9d558bd..9a0951e 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -11,25 +11,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # Batch uninstall with a single confirmation. -# User data detection patterns (prompt user to backup if found). -readonly SENSITIVE_DATA_PATTERNS=( - "\.warp" # Warp terminal configs/themes - "/\.config/" # Standard Unix config directory - "/themes/" # Theme customizations - "/settings/" # Settings directories - "/Application Support/[^/]+/User Data" # Chrome/Electron user data - "/Preferences/[^/]+\.plist" # User preference files - "/Documents/" # User documents - "/\.ssh/" # SSH keys and configs (critical) - "/\.gnupg/" # GPG keys (critical) -) - -# Join patterns into a single regex for grep. -SENSITIVE_DATA_REGEX=$( - IFS='|' - echo "${SENSITIVE_DATA_PATTERNS[*]}" -) - # High-performance sensitive data detection (pure Bash, no subprocess) # Faster than grep for batch operations, especially when processing many apps has_sensitive_data() { diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index d7f2d01..2906e16 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -174,17 +174,18 @@ brew_uninstall_cask() { debug_log "Attempting brew uninstall --cask $cask_name" # Ensure we have sudo access if needed, to prevent brew from hanging on password prompt - if ! sudo -n true 2>/dev/null; then - sudo -v + if [[ "${NONINTERACTIVE:-}" != "1" && -t 0 && -t 1 ]]; then + if ! sudo -n true 2>/dev/null; then + sudo -v + fi fi local uninstall_ok=false local brew_exit=0 # Run with timeout to prevent hangs from problematic cask scripts - if run_with_timeout 300 \ - env HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ - brew uninstall --cask "$cask_name" 2>&1; then + if HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + run_with_timeout 300 brew uninstall --cask "$cask_name" 2>&1; then uninstall_ok=true else brew_exit=$? diff --git a/tests/uninstall.bats b/tests/uninstall.bats index 59652d3..cd6b24e 100644 --- a/tests/uninstall.bats +++ b/tests/uninstall.bats @@ -39,8 +39,6 @@ create_app_artifacts() { mkdir -p "$HOME/Library/Saved Application State/com.example.TestApp.savedState" mkdir -p "$HOME/Library/LaunchAgents" touch "$HOME/Library/LaunchAgents/com.example.TestApp.plist" - mkdir -p "$HOME/Library/LaunchDaemons" - touch "$HOME/Library/LaunchDaemons/com.example.TestApp.plist" } @test "find_app_files discovers user-level leftovers" { @@ -60,7 +58,6 @@ EOF [[ "$result" == *"Saved Application State/com.example.TestApp.savedState"* ]] [[ "$result" == *"Containers/com.example.TestApp"* ]] [[ "$result" == *"LaunchAgents/com.example.TestApp.plist"* ]] - [[ "$result" == *"LaunchDaemons/com.example.TestApp.plist"* ]] } @test "calculate_total_size returns aggregate kilobytes" { @@ -121,7 +118,6 @@ batch_uninstall_applications [[ ! -d "$HOME/Library/Caches/TestApp" ]] || exit 1 [[ ! -f "$HOME/Library/Preferences/com.example.TestApp.plist" ]] || exit 1 [[ ! -f "$HOME/Library/LaunchAgents/com.example.TestApp.plist" ]] || exit 1 -[[ ! -f "$HOME/Library/LaunchDaemons/com.example.TestApp.plist" ]] || exit 1 EOF [ "$status" -eq 0 ] From 1534018974e6a047698a7a6008434891a3f7b356 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 17 Jan 2026 01:50:08 +0000 Subject: [PATCH 06/91] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 45 ++++++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 858e874..75befa1 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -200,6 +200,17 @@ + + + + + + + + bikraj2 + + + @@ -210,7 +221,7 @@ bunizao - + @@ -221,7 +232,7 @@ frozturk - + @@ -232,7 +243,7 @@ huyixi - + @@ -243,7 +254,7 @@ purofle - + @@ -254,7 +265,7 @@ NanmiCoder - + @@ -265,7 +276,7 @@ Schlauer-Hax - + @@ -276,7 +287,7 @@ anonymort - + @@ -287,7 +298,7 @@ khipu-luke - + @@ -298,7 +309,7 @@ LmanTW - + @@ -309,7 +320,7 @@ kwakubiney - + @@ -320,7 +331,7 @@ kowyo - + @@ -331,7 +342,7 @@ jalen0x - + @@ -342,7 +353,7 @@ Hensell - + @@ -353,7 +364,7 @@ Copper-Eye - + @@ -364,7 +375,7 @@ ClathW - + @@ -375,7 +386,7 @@ andmev - + From fe296de814d343bc4ec5060d3ae35ac31d276ee2 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 17 Jan 2026 01:53:40 +0000 Subject: [PATCH 07/91] chore: auto format code --- lib/core/app_protection.sh | 184 ++++++++++++++++++------------------- lib/core/common.sh | 30 +++--- lib/core/file_ops.sh | 102 ++++++++++---------- lib/uninstall/batch.sh | 126 ++++++++++++------------- lib/uninstall/brew.sh | 26 +++--- 5 files changed, 234 insertions(+), 234 deletions(-) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 05955cd..0a22392 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -13,7 +13,7 @@ _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" [[ -z "${MOLE_BASE_LOADED:-}" ]] && source "$_MOLE_CORE_DIR/base.sh" # Declare WHITELIST_PATTERNS if not already set (used by is_path_whitelisted) -if ! declare -p WHITELIST_PATTERNS &>/dev/null; then +if ! declare -p WHITELIST_PATTERNS &> /dev/null; then declare -a WHITELIST_PATTERNS=() fi @@ -439,12 +439,12 @@ is_critical_system_component() { lower=$(echo "$token" | LC_ALL=C tr '[:upper:]' '[:lower:]') case "$lower" in - *backgroundtaskmanagement* | *loginitems* | *systempreferences* | *systemsettings* | *settings* | *preferences* | *controlcenter* | *biometrickit* | *sfl* | *tcc*) - return 0 - ;; - *) - return 1 - ;; + *backgroundtaskmanagement* | *loginitems* | *systempreferences* | *systemsettings* | *settings* | *preferences* | *controlcenter* | *biometrickit* | *sfl* | *tcc*) + return 0 + ;; + *) + return 1 + ;; esac } @@ -522,25 +522,25 @@ should_protect_path() { # 2. Protect caches critical for system UI rendering # These caches are essential for modern macOS (Sonoma/Sequoia) system UI rendering case "$path" in - # System Settings and Control Center caches (CRITICAL - prevents blank panel bug) - *com.apple.systempreferences.cache* | *com.apple.Settings.cache* | *com.apple.controlcenter.cache*) - return 0 - ;; - # Finder and Dock (system essential) - *com.apple.finder.cache* | *com.apple.dock.cache*) - return 0 - ;; - # System XPC services and sandboxed containers - */Library/Containers/com.apple.Settings* | */Library/Containers/com.apple.SystemSettings* | */Library/Containers/com.apple.controlcenter*) - return 0 - ;; - */Library/Group\ Containers/com.apple.systempreferences* | */Library/Group\ Containers/com.apple.Settings*) - return 0 - ;; - # Shared file lists for System Settings (macOS Sequoia) - Issue #136 - */com.apple.sharedfilelist/*com.apple.Settings* | */com.apple.sharedfilelist/*com.apple.SystemSettings* | */com.apple.sharedfilelist/*systempreferences*) - return 0 - ;; + # System Settings and Control Center caches (CRITICAL - prevents blank panel bug) + *com.apple.systempreferences.cache* | *com.apple.Settings.cache* | *com.apple.controlcenter.cache*) + return 0 + ;; + # Finder and Dock (system essential) + *com.apple.finder.cache* | *com.apple.dock.cache*) + return 0 + ;; + # System XPC services and sandboxed containers + */Library/Containers/com.apple.Settings* | */Library/Containers/com.apple.SystemSettings* | */Library/Containers/com.apple.controlcenter*) + return 0 + ;; + */Library/Group\ Containers/com.apple.systempreferences* | */Library/Group\ Containers/com.apple.Settings*) + return 0 + ;; + # Shared file lists for System Settings (macOS Sequoia) - Issue #136 + */com.apple.sharedfilelist/*com.apple.Settings* | */com.apple.sharedfilelist/*com.apple.SystemSettings* | */com.apple.sharedfilelist/*systempreferences*) + return 0 + ;; esac # 3. Extract bundle ID from sandbox paths @@ -555,24 +555,24 @@ should_protect_path() { # 4. Check for specific hardcoded critical patterns case "$path" in - *com.apple.Settings* | *com.apple.SystemSettings* | *com.apple.controlcenter* | *com.apple.finder* | *com.apple.dock*) - return 0 - ;; + *com.apple.Settings* | *com.apple.SystemSettings* | *com.apple.controlcenter* | *com.apple.finder* | *com.apple.dock*) + return 0 + ;; esac # 5. Protect critical preference files and user data case "$path" in - */Library/Preferences/com.apple.dock.plist | */Library/Preferences/com.apple.finder.plist) - return 0 - ;; - # Bluetooth and WiFi configurations - */ByHost/com.apple.bluetooth.* | */ByHost/com.apple.wifi.*) - return 0 - ;; - # iCloud Drive - protect user's cloud synced data - */Library/Mobile\ Documents* | */Mobile\ Documents*) - return 0 - ;; + */Library/Preferences/com.apple.dock.plist | */Library/Preferences/com.apple.finder.plist) + return 0 + ;; + # Bluetooth and WiFi configurations + */ByHost/com.apple.bluetooth.* | */ByHost/com.apple.wifi.*) + return 0 + ;; + # iCloud Drive - protect user's cloud synced data + */Library/Mobile\ Documents* | */Mobile\ Documents*) + return 0 + ;; esac # 6. Match full path against protected patterns @@ -611,9 +611,9 @@ is_path_whitelisted() { local check_pattern="${pattern%/}" local has_glob="false" case "$check_pattern" in - *\** | *\?* | *\[*) - has_glob="true" - ;; + *\** | *\?* | *\[*) + has_glob="true" + ;; esac # Check for exact match or glob pattern match @@ -716,17 +716,17 @@ find_app_files() { # Safety check: Skip if path ends with a common directory name (indicates empty app_name/bundle_id) # This prevents deletion of entire Library subdirectories when bundle_id is empty case "$expanded_path" in - */Library/Application\ Support | */Library/Application\ Support/ | \ - */Library/Caches | */Library/Caches/ | \ - */Library/Logs | */Library/Logs/ | \ - */Library/Containers | */Library/Containers/ | \ - */Library/WebKit | */Library/WebKit/ | \ - */Library/HTTPStorages | */Library/HTTPStorages/ | \ - */Library/Application\ Scripts | */Library/Application\ Scripts/ | \ - */Library/Autosave\ Information | */Library/Autosave\ Information/ | \ - */Library/Group\ Containers | */Library/Group\ Containers/) - continue - ;; + */Library/Application\ Support | */Library/Application\ Support/ | \ + */Library/Caches | */Library/Caches/ | \ + */Library/Logs | */Library/Logs/ | \ + */Library/Containers | */Library/Containers/ | \ + */Library/WebKit | */Library/WebKit/ | \ + */Library/HTTPStorages | */Library/HTTPStorages/ | \ + */Library/Application\ Scripts | */Library/Application\ Scripts/ | \ + */Library/Autosave\ Information | */Library/Autosave\ Information/ | \ + */Library/Group\ Containers | */Library/Group\ Containers/) + continue + ;; esac files_to_clean+=("$expanded_path") @@ -737,13 +737,13 @@ find_app_files() { [[ -f ~/Library/Preferences/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id.plist") [[ -d ~/Library/Preferences/ByHost ]] && while IFS= read -r -d '' pref; do files_to_clean+=("$pref") - done < <(command find ~/Library/Preferences/ByHost -maxdepth 1 \( -name "$bundle_id*.plist" \) -print0 2>/dev/null) + done < <(command find ~/Library/Preferences/ByHost -maxdepth 1 \( -name "$bundle_id*.plist" \) -print0 2> /dev/null) # Group Containers (special handling) if [[ -d ~/Library/Group\ Containers ]]; then while IFS= read -r -d '' container; do files_to_clean+=("$container") - done < <(command find ~/Library/Group\ Containers -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2>/dev/null) + done < <(command find ~/Library/Group\ Containers -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null) fi fi @@ -758,7 +758,7 @@ find_app_files() { continue fi files_to_clean+=("$plist") - done < <(command find ~/Library/LaunchAgents -maxdepth 1 -name "*$app_name*.plist" -print0 2>/dev/null) + done < <(command find ~/Library/LaunchAgents -maxdepth 1 -name "*$app_name*.plist" -print0 2> /dev/null) fi # Handle specialized toolchains and development environments @@ -774,7 +774,7 @@ find_app_files() { for d in ~/AndroidStudioProjects ~/Library/Android ~/.android; do [[ -d "$d" ]] && files_to_clean+=("$d") done - [[ -d ~/Library/Application\ Support/Google ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find ~/Library/Application\ Support/Google -maxdepth 1 -name "AndroidStudio*" -print0 2>/dev/null) + [[ -d ~/Library/Application\ Support/Google ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find ~/Library/Application\ Support/Google -maxdepth 1 -name "AndroidStudio*" -print0 2> /dev/null) fi # 3. Xcode (Apple) @@ -786,7 +786,7 @@ find_app_files() { # 4. JetBrains (IDE settings) if [[ "$bundle_id" =~ jetbrains ]] || [[ "$app_name" =~ IntelliJ|PyCharm|WebStorm|GoLand|RubyMine|PhpStorm|CLion|DataGrip|Rider ]]; then for base in ~/Library/Application\ Support/JetBrains ~/Library/Caches/JetBrains ~/Library/Logs/JetBrains; do - [[ -d "$base" ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find "$base" -maxdepth 1 -name "${app_name}*" -print0 2>/dev/null) + [[ -d "$base" ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find "$base" -maxdepth 1 -name "${app_name}*" -print0 2> /dev/null) done fi @@ -853,11 +853,11 @@ find_app_system_files() { # Safety check: Skip if path ends with a common directory name (indicates empty app_name/bundle_id) case "$p" in - /Library/Application\ Support | /Library/Application\ Support/ | \ - /Library/Caches | /Library/Caches/ | \ - /Library/Logs | /Library/Logs/) - continue - ;; + /Library/Application\ Support | /Library/Application\ Support/ | \ + /Library/Caches | /Library/Caches/ | \ + /Library/Logs | /Library/Logs/) + continue + ;; esac system_files+=("$p") @@ -868,7 +868,7 @@ find_app_system_files() { for base in /Library/LaunchAgents /Library/LaunchDaemons; do [[ -d "$base" ]] && while IFS= read -r -d '' plist; do system_files+=("$plist") - done < <(command find "$base" -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2>/dev/null) + done < <(command find "$base" -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null) done fi @@ -877,11 +877,11 @@ find_app_system_files() { if [[ -n "$bundle_id" && "$bundle_id" != "unknown" && ${#bundle_id} -gt 3 ]]; then [[ -d /Library/PrivilegedHelperTools ]] && while IFS= read -r -d '' helper; do system_files+=("$helper") - done < <(command find /Library/PrivilegedHelperTools -maxdepth 1 \( -name "$bundle_id*" \) -print0 2>/dev/null) + done < <(command find /Library/PrivilegedHelperTools -maxdepth 1 \( -name "$bundle_id*" \) -print0 2> /dev/null) [[ -d /private/var/db/receipts ]] && while IFS= read -r -d '' receipt; do system_files+=("$receipt") - done < <(command find /private/var/db/receipts -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2>/dev/null) + done < <(command find /private/var/db/receipts -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null) fi local receipt_files="" @@ -926,7 +926,7 @@ find_app_receipt_files() { if [[ -d /private/var/db/receipts ]]; then while IFS= read -r -d '' bom; do bom_files+=("$bom") - done < <(find /private/var/db/receipts -maxdepth 1 -name "${bundle_id}*.bom" -print0 2>/dev/null) + done < <(find /private/var/db/receipts -maxdepth 1 -name "${bundle_id}*.bom" -print0 2> /dev/null) fi # Process bom files if any found @@ -938,7 +938,7 @@ find_app_receipt_files() { # lsbom -f: file paths only # -s: suppress output (convert to text) local bom_content - bom_content=$(lsbom -f -s "$bom_file" 2>/dev/null) + bom_content=$(lsbom -f -s "$bom_file" 2> /dev/null) while IFS= read -r file_path; do # Standardize path (remove leading dot) @@ -965,21 +965,21 @@ find_app_receipt_files() { # Whitelisted prefixes (exclude /Users, /usr, /opt) case "$clean_path" in - /Applications/*) is_safe=true ;; - /Library/Application\ Support/*) is_safe=true ;; - /Library/Caches/*) is_safe=true ;; - /Library/Logs/*) is_safe=true ;; - /Library/Preferences/*) is_safe=true ;; - /Library/LaunchAgents/*) is_safe=true ;; - /Library/LaunchDaemons/*) is_safe=true ;; - /Library/PrivilegedHelperTools/*) is_safe=true ;; - /Library/Extensions/*) is_safe=false ;; - *) is_safe=false ;; + /Applications/*) is_safe=true ;; + /Library/Application\ Support/*) is_safe=true ;; + /Library/Caches/*) is_safe=true ;; + /Library/Logs/*) is_safe=true ;; + /Library/Preferences/*) is_safe=true ;; + /Library/LaunchAgents/*) is_safe=true ;; + /Library/LaunchDaemons/*) is_safe=true ;; + /Library/PrivilegedHelperTools/*) is_safe=true ;; + /Library/Extensions/*) is_safe=false ;; + *) is_safe=false ;; esac # Hard blocks case "$clean_path" in - /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/*) is_safe=false ;; + /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/*) is_safe=false ;; esac if [[ "$is_safe" == "true" && -e "$clean_path" ]]; then @@ -988,7 +988,7 @@ find_app_receipt_files() { continue fi - if declare -f should_protect_path >/dev/null 2>&1; then + if declare -f should_protect_path > /dev/null 2>&1; then if should_protect_path "$clean_path"; then continue fi @@ -997,7 +997,7 @@ find_app_receipt_files() { receipt_files+=("$clean_path") fi - done <<<"$bom_content" + done <<< "$bom_content" done fi if [[ ${#receipt_files[@]} -gt 0 ]]; then @@ -1014,34 +1014,34 @@ force_kill_app() { # 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 "") + 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 + 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 + 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 + 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 + 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 + 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 @@ -1049,7 +1049,7 @@ force_kill_app() { # 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 + if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then return 0 fi sleep 1 @@ -1057,7 +1057,7 @@ force_kill_app() { done # Still running after all attempts - pgrep -x "$match_pattern" >/dev/null 2>&1 && return 1 || return 0 + pgrep -x "$match_pattern" > /dev/null 2>&1 && return 1 || return 0 } # Note: calculate_total_size() is defined in lib/core/file_ops.sh diff --git a/lib/core/common.sh b/lib/core/common.sh index ed3a225..21a427e 100755 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -43,9 +43,9 @@ update_via_homebrew() { echo "Updating Homebrew..." fi - brew update >"$temp_update" 2>&1 & + brew update > "$temp_update" 2>&1 & local update_pid=$! - wait $update_pid 2>/dev/null || true # Continue even if brew update fails + wait $update_pid 2> /dev/null || true # Continue even if brew update fails if [[ -t 1 ]]; then stop_inline_spinner @@ -58,9 +58,9 @@ update_via_homebrew() { echo "Upgrading Mole..." fi - brew upgrade mole >"$temp_upgrade" 2>&1 & + brew upgrade mole > "$temp_upgrade" 2>&1 & local upgrade_pid=$! - wait $upgrade_pid 2>/dev/null || true # Continue even if brew upgrade fails + wait $upgrade_pid 2> /dev/null || true # Continue even if brew upgrade fails local upgrade_output upgrade_output=$(cat "$temp_upgrade") @@ -78,7 +78,7 @@ update_via_homebrew() { if echo "$upgrade_output" | grep -q "already installed"; then local installed_version - installed_version=$(brew list --versions mole 2>/dev/null | awk '{print $2}') + 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 "" @@ -89,14 +89,14 @@ update_via_homebrew() { else echo "$upgrade_output" | grep -Ev "^(==>|Updating Homebrew|Warning:)" || true local new_version - new_version=$(brew list --versions mole 2>/dev/null | awk '{print $2}') + new_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') echo "" echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-$current_version})" echo "" fi # Clear update cache (suppress errors if cache doesn't exist or is locked) - rm -f "$HOME/.cache/mole/version_check" "$HOME/.cache/mole/update_message" 2>/dev/null || true + rm -f "$HOME/.cache/mole/version_check" "$HOME/.cache/mole/update_message" 2> /dev/null || true } # Remove applications from Dock @@ -133,16 +133,16 @@ remove_apps_from_dock() { fi if [[ -e "$app_path" ]]; then - if full_path=$(cd "$(dirname "$app_path")" 2>/dev/null && pwd); then + if full_path=$(cd "$(dirname "$app_path")" 2> /dev/null && pwd); then full_path="$full_path/$(basename "$app_path")" else continue fi else case "$app_path" in - ~/*) full_path="$HOME/${app_path#~/}" ;; - /*) full_path="$app_path" ;; - *) continue ;; + ~/*) full_path="$HOME/${app_path#~/}" ;; + /*) full_path="$app_path" ;; + *) continue ;; esac fi @@ -154,11 +154,11 @@ remove_apps_from_dock() { local i=0 while true; do local label - label=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-label" "$plist" 2>/dev/null || echo "") + label=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-label" "$plist" 2> /dev/null || echo "") [[ -z "$label" ]] && break local url - url=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-data:_CFURLString" "$plist" 2>/dev/null || echo "") + url=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-data:_CFURLString" "$plist" 2> /dev/null || echo "") [[ -z "$url" ]] && { ((i++)) continue @@ -166,7 +166,7 @@ remove_apps_from_dock() { # Match by URL-encoded path to handle spaces in app names if [[ -n "$encoded_path" && "$url" == *"$encoded_path"* ]]; then - if /usr/libexec/PlistBuddy -c "Delete :persistent-apps:$i" "$plist" 2>/dev/null; then + if /usr/libexec/PlistBuddy -c "Delete :persistent-apps:$i" "$plist" 2> /dev/null; then changed=true # After deletion, current index i now points to the next item continue @@ -178,6 +178,6 @@ remove_apps_from_dock() { if [[ "$changed" == "true" ]]; then # Restart Dock to apply changes from the plist - killall Dock 2>/dev/null || true + killall Dock 2> /dev/null || true fi } diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index cc6ca8e..9044dd6 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -42,7 +42,7 @@ validate_path_for_deletion() { # Check symlink target if path is a symbolic link if [[ -L "$path" ]]; then local link_target - link_target=$(readlink "$path" 2>/dev/null) || { + link_target=$(readlink "$path" 2> /dev/null) || { log_error "Cannot read symlink: $path" return 1 } @@ -52,16 +52,16 @@ validate_path_for_deletion() { if [[ "$link_target" != /* ]]; then local link_dir link_dir=$(dirname "$path") - resolved_target=$(cd "$link_dir" 2>/dev/null && cd "$(dirname "$link_target")" 2>/dev/null && pwd)/$(basename "$link_target") || resolved_target="" + resolved_target=$(cd "$link_dir" 2> /dev/null && cd "$(dirname "$link_target")" 2> /dev/null && pwd)/$(basename "$link_target") || resolved_target="" fi # Validate resolved target against protected paths if [[ -n "$resolved_target" ]]; then case "$resolved_target" in - /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) - log_error "Symlink points to protected system path: $path -> $resolved_target" - return 1 - ;; + /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) + log_error "Symlink points to protected system path: $path -> $resolved_target" + return 1 + ;; esac fi fi @@ -88,47 +88,47 @@ validate_path_for_deletion() { # Allow deletion of coresymbolicationd cache (safe system cache that can be rebuilt) case "$path" in - /System/Library/Caches/com.apple.coresymbolicationd/data | /System/Library/Caches/com.apple.coresymbolicationd/data/*) - return 0 - ;; + /System/Library/Caches/com.apple.coresymbolicationd/data | /System/Library/Caches/com.apple.coresymbolicationd/data/*) + return 0 + ;; esac # Allow known safe paths under /private case "$path" in - /private/tmp | /private/tmp/* | \ - /private/var/tmp | /private/var/tmp/* | \ - /private/var/log | /private/var/log/* | \ - /private/var/folders | /private/var/folders/* | \ - /private/var/db/diagnostics | /private/var/db/diagnostics/* | \ - /private/var/db/DiagnosticPipeline | /private/var/db/DiagnosticPipeline/* | \ - /private/var/db/powerlog | /private/var/db/powerlog/* | \ - /private/var/db/reportmemoryexception | /private/var/db/reportmemoryexception/*) - return 0 - ;; + /private/tmp | /private/tmp/* | \ + /private/var/tmp | /private/var/tmp/* | \ + /private/var/log | /private/var/log/* | \ + /private/var/folders | /private/var/folders/* | \ + /private/var/db/diagnostics | /private/var/db/diagnostics/* | \ + /private/var/db/DiagnosticPipeline | /private/var/db/DiagnosticPipeline/* | \ + /private/var/db/powerlog | /private/var/db/powerlog/* | \ + /private/var/db/reportmemoryexception | /private/var/db/reportmemoryexception/*) + return 0 + ;; esac # Check path isn't critical system directory case "$path" in - / | /bin | /bin/* | /sbin | /sbin/* | /usr | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /usr/lib | /usr/lib/* | /System | /System/* | /Library/Extensions) - log_error "Path validation failed: critical system directory: $path" - return 1 - ;; - /private) - log_error "Path validation failed: critical system directory: $path" - return 1 - ;; - /etc | /etc/* | /private/etc | /private/etc/*) - log_error "Path validation failed: /etc contains critical system files: $path" - return 1 - ;; - /var | /var/db | /var/db/* | /private/var | /private/var/db | /private/var/db/*) - log_error "Path validation failed: /var/db contains system databases: $path" - return 1 - ;; + / | /bin | /bin/* | /sbin | /sbin/* | /usr | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /usr/lib | /usr/lib/* | /System | /System/* | /Library/Extensions) + log_error "Path validation failed: critical system directory: $path" + return 1 + ;; + /private) + log_error "Path validation failed: critical system directory: $path" + return 1 + ;; + /etc | /etc/* | /private/etc | /private/etc/*) + log_error "Path validation failed: /etc contains critical system files: $path" + return 1 + ;; + /var | /var/db | /var/db/* | /private/var | /private/var/db | /private/var/db/*) + log_error "Path validation failed: /var/db contains system databases: $path" + return 1 + ;; esac # Check if path is protected (keychains, system settings, etc) - if declare -f should_protect_path >/dev/null 2>&1; then + if declare -f should_protect_path > /dev/null 2>&1; then if should_protect_path "$path"; then if [[ "${MO_DEBUG:-0}" == "1" ]]; then log_warning "Path validation: protected path skipped: $path" @@ -171,16 +171,16 @@ safe_remove() { if [[ -e "$path" ]]; then local size_kb - size_kb=$(get_path_size_kb "$path" 2>/dev/null || echo "0") + size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0") if [[ "$size_kb" -gt 0 ]]; then file_size=$(bytes_to_human "$((size_kb * 1024))") fi if [[ -f "$path" || -d "$path" ]] && ! [[ -L "$path" ]]; then local mod_time - mod_time=$(stat -f%m "$path" 2>/dev/null || echo "0") + mod_time=$(stat -f%m "$path" 2> /dev/null || echo "0") local now - now=$(date +%s 2>/dev/null || echo "0") + now=$(date +%s 2> /dev/null || echo "0") if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then file_age=$(((now - mod_time) / 86400)) fi @@ -248,18 +248,18 @@ safe_sudo_remove() { local file_size="" local file_age="" - if sudo test -e "$path" 2>/dev/null; then + if sudo test -e "$path" 2> /dev/null; then local size_kb - size_kb=$(sudo du -sk "$path" 2>/dev/null | awk '{print $1}' || echo "0") + size_kb=$(sudo du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0") if [[ "$size_kb" -gt 0 ]]; then file_size=$(bytes_to_human "$((size_kb * 1024))") fi - if sudo test -f "$path" 2>/dev/null || sudo test -d "$path" 2>/dev/null; then + if sudo test -f "$path" 2> /dev/null || sudo test -d "$path" 2> /dev/null; then local mod_time - mod_time=$(sudo stat -f%m "$path" 2>/dev/null || echo "0") + mod_time=$(sudo stat -f%m "$path" 2> /dev/null || echo "0") local now - now=$(date +%s 2>/dev/null || echo "0") + now=$(date +%s 2> /dev/null || echo "0") if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then file_age=$(((now - mod_time) / 86400)) fi @@ -276,7 +276,7 @@ safe_sudo_remove() { debug_log "Removing (sudo): $path" # Perform the deletion - if sudo rm -rf "$path" 2>/dev/null; then # SAFE: safe_sudo_remove implementation + if sudo rm -rf "$path" 2> /dev/null; then # SAFE: safe_sudo_remove implementation return 0 else log_error "Failed to remove (sudo): $path" @@ -325,7 +325,7 @@ safe_find_delete() { continue fi safe_remove "$match" true || true - done < <(command find "$base_dir" "${find_args[@]}" -print0 2>/dev/null || true) + done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) return 0 } @@ -338,12 +338,12 @@ safe_sudo_find_delete() { local type_filter="${4:-f}" # Validate base directory (use sudo for permission-restricted dirs) - if ! sudo test -d "$base_dir" 2>/dev/null; then + if ! sudo test -d "$base_dir" 2> /dev/null; then debug_log "Directory does not exist (skipping): $base_dir" return 0 fi - if sudo test -L "$base_dir" 2>/dev/null; then + if sudo test -L "$base_dir" 2> /dev/null; then log_error "Refusing to search symlinked directory: $base_dir" return 1 fi @@ -367,7 +367,7 @@ safe_sudo_find_delete() { continue fi safe_sudo_remove "$match" || true - done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2>/dev/null || true) + done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) return 0 } @@ -387,7 +387,7 @@ get_path_size_kb() { # Use || echo 0 to ensure failure in du (e.g. permission error) doesn't exit script under set -e # Pipefail would normally cause the pipeline to fail if du fails, but || handle catches it. local size - size=$(command du -sk "$path" 2>/dev/null | awk 'NR==1 {print $1; exit}' || true) + size=$(command du -sk "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true) # Ensure size is a valid number (fix for non-numeric du output) if [[ "$size" =~ ^[0-9]+$ ]]; then @@ -408,7 +408,7 @@ calculate_total_size() { size_kb=$(get_path_size_kb "$file") ((total_kb += size_kb)) fi - done <<<"$files" + done <<< "$files" echo "$total_kb" } diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 9a0951e..b54fe08 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -22,17 +22,17 @@ has_sensitive_data() { # Use Bash native pattern matching (faster than spawning grep) case "$file" in - */.warp* | */.config/* | */themes/* | */settings/* | */User\ Data/* | \ - */.ssh/* | */.gnupg/* | */Documents/* | */Preferences/*.plist | \ - */Desktop/* | */Downloads/* | */Movies/* | */Music/* | */Pictures/* | \ - */.password* | */.token* | */.auth* | */keychain* | \ - */Passwords/* | */Accounts/* | */Cookies/* | \ - */.aws/* | */.docker/config.json | */.kube/* | \ - */credentials/* | */secrets/*) - return 0 # Found sensitive data - ;; + */.warp* | */.config/* | */themes/* | */settings/* | */User\ Data/* | \ + */.ssh/* | */.gnupg/* | */Documents/* | */Preferences/*.plist | \ + */Desktop/* | */Downloads/* | */Movies/* | */Music/* | */Pictures/* | \ + */.password* | */.token* | */.auth* | */keychain* | \ + */Passwords/* | */Accounts/* | */Cookies/* | \ + */.aws/* | */.docker/config.json | */.kube/* | \ + */credentials/* | */secrets/*) + return 0 # Found sensitive data + ;; esac - done <<<"$files" + done <<< "$files" return 1 # Not found } @@ -44,8 +44,8 @@ decode_file_list() { local decoded # macOS uses -D, GNU uses -d. Always return 0 for set -e safety. - if ! decoded=$(printf '%s' "$encoded" | base64 -D 2>/dev/null); then - if ! decoded=$(printf '%s' "$encoded" | base64 -d 2>/dev/null); then + if ! decoded=$(printf '%s' "$encoded" | base64 -D 2> /dev/null); then + if ! decoded=$(printf '%s' "$encoded" | base64 -d 2> /dev/null); then log_error "Failed to decode file list for $app_name" >&2 echo "" return 0 # Return success with empty string @@ -64,7 +64,7 @@ decode_file_list() { echo "" return 0 # Return success with empty string fi - done <<<"$decoded" + done <<< "$decoded" echo "$decoded" return 0 @@ -80,20 +80,20 @@ stop_launch_services() { if [[ -d ~/Library/LaunchAgents ]]; then while IFS= read -r -d '' plist; do - launchctl unload "$plist" 2>/dev/null || true - done < <(find ~/Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2>/dev/null) + launchctl unload "$plist" 2> /dev/null || true + done < <(find ~/Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) fi if [[ "$has_system_files" == "true" ]]; then if [[ -d /Library/LaunchAgents ]]; then while IFS= read -r -d '' plist; do - sudo launchctl unload "$plist" 2>/dev/null || true - done < <(find /Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2>/dev/null) + sudo launchctl unload "$plist" 2> /dev/null || true + done < <(find /Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) fi if [[ -d /Library/LaunchDaemons ]]; then while IFS= read -r -d '' plist; do - sudo launchctl unload "$plist" 2>/dev/null || true - done < <(find /Library/LaunchDaemons -maxdepth 1 -name "${bundle_id}*.plist" -print0 2>/dev/null) + sudo launchctl unload "$plist" 2> /dev/null || true + done < <(find /Library/LaunchDaemons -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) fi fi } @@ -115,7 +115,7 @@ remove_login_item() { local escaped_name="${clean_name//\\/\\\\}" escaped_name="${escaped_name//\"/\\\"}" - osascript <<-EOF >/dev/null 2>&1 || true + osascript <<- EOF > /dev/null 2>&1 || true tell application "System Events" try set itemCount to count of login items @@ -149,9 +149,9 @@ remove_file_list() { if [[ -L "$file" ]]; then if [[ "$use_sudo" == "true" ]]; then - sudo rm "$file" 2>/dev/null && ((++count)) || true + sudo rm "$file" 2> /dev/null && ((++count)) || true else - rm "$file" 2>/dev/null && ((++count)) || true + rm "$file" 2> /dev/null && ((++count)) || true fi else if [[ "$use_sudo" == "true" ]]; then @@ -160,7 +160,7 @@ remove_file_list() { safe_remove "$file" true && ((++count)) || true fi fi - done <<<"$file_list" + done <<< "$file_list" echo "$count" } @@ -184,15 +184,15 @@ batch_uninstall_applications() { if [[ -t 1 ]]; then start_inline_spinner "Scanning files..."; fi for selected_app in "${selected_apps[@]}"; do [[ -z "$selected_app" ]] && continue - IFS='|' read -r _ app_path app_name bundle_id _ _ <<<"$selected_app" + IFS='|' read -r _ app_path app_name bundle_id _ _ <<< "$selected_app" # Check running app by bundle executable if available. local exec_name="" if [[ -e "$app_path/Contents/Info.plist" ]]; then - exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2>/dev/null || echo "") + exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "") fi local check_pattern="${exec_name:-$app_name}" - if pgrep -x "$check_pattern" >/dev/null 2>&1; then + if pgrep -x "$check_pattern" > /dev/null 2>&1; then running_apps+=("$app_name") fi @@ -259,7 +259,7 @@ batch_uninstall_applications() { # Warn if user data is detected. local has_user_data=false for detail in "${app_details[@]}"; do - IFS='|' read -r _ _ _ _ _ _ has_sensitive_data <<<"$detail" + IFS='|' read -r _ _ _ _ _ _ has_sensitive_data <<< "$detail" if [[ "$has_sensitive_data" == "true" ]]; then has_user_data=true break @@ -272,7 +272,7 @@ batch_uninstall_applications() { fi for detail in "${app_details[@]}"; do - IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag is_brew_cask cask_name <<<"$detail" + IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag is_brew_cask cask_name <<< "$detail" local app_size_display=$(bytes_to_human "$((total_kb * 1024))") local brew_tag="" @@ -295,7 +295,7 @@ batch_uninstall_applications() { fi ((file_count++)) fi - done <<<"$related_files" + done <<< "$related_files" # Show system files (limit to 5). local sys_file_count=0 @@ -306,7 +306,7 @@ batch_uninstall_applications() { fi ((sys_file_count++)) fi - done <<<"$system_files" + done <<< "$system_files" local total_hidden=$((file_count > max_files ? file_count - max_files : 0)) ((total_hidden += sys_file_count > max_files ? sys_file_count - max_files : 0)) @@ -332,24 +332,24 @@ batch_uninstall_applications() { IFS= read -r -s -n1 key || key="" drain_pending_input # Clean up any escape sequence remnants case "$key" in - $'\e' | q | Q) - echo "" - echo "" - return 0 - ;; - "" | $'\n' | $'\r' | y | Y) - echo "" # Move to next line - ;; - *) - echo "" - echo "" - return 0 - ;; + $'\e' | q | Q) + echo "" + echo "" + return 0 + ;; + "" | $'\n' | $'\r' | y | Y) + echo "" # Move to next line + ;; + *) + echo "" + echo "" + return 0 + ;; esac # Request sudo if needed. if [[ ${#sudo_apps[@]} -gt 0 ]]; then - if ! sudo -n true 2>/dev/null; then + if ! sudo -n true 2> /dev/null; then if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then echo "" log_error "Admin access denied" @@ -359,12 +359,12 @@ batch_uninstall_applications() { # Keep sudo alive during uninstall. parent_pid=$$ (while true; do - if ! kill -0 "$parent_pid" 2>/dev/null; then + if ! kill -0 "$parent_pid" 2> /dev/null; then exit 0 fi sudo -n true sleep 60 - done 2>/dev/null) & + done 2> /dev/null) & sudo_keepalive_pid=$! fi @@ -376,7 +376,7 @@ batch_uninstall_applications() { local current_index=0 for detail in "${app_details[@]}"; do ((current_index++)) - IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name <<<"$detail" + IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name <<< "$detail" local related_files=$(decode_file_list "$encoded_files" "$app_name") local system_files=$(decode_file_list "$encoded_system_files" "$app_name") local reason="" @@ -439,24 +439,24 @@ batch_uninstall_applications() { # Remove related files if app removal succeeded. if [[ -z "$reason" ]]; then - remove_file_list "$related_files" "false" >/dev/null + remove_file_list "$related_files" "false" > /dev/null # If brew successfully uninstalled the cask, avoid deleting # system-level files Mole discovered. Brew manages its own # receipts/symlinks and we don't want to fight it. if [[ "$used_brew_successfully" != "true" ]]; then - remove_file_list "$system_files" "true" >/dev/null + remove_file_list "$system_files" "true" > /dev/null fi # Clean up macOS defaults (preference domains). if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then - if defaults read "$bundle_id" &>/dev/null; then - defaults delete "$bundle_id" 2>/dev/null || true + if defaults read "$bundle_id" &> /dev/null; then + defaults delete "$bundle_id" 2> /dev/null || true fi # ByHost preferences (machine-specific). if [[ -d ~/Library/Preferences/ByHost ]]; then - find ~/Library/Preferences/ByHost -maxdepth 1 -name "${bundle_id}.*.plist" -delete 2>/dev/null || true + find ~/Library/Preferences/ByHost -maxdepth 1 -name "${bundle_id}.*.plist" -delete 2> /dev/null || true fi fi @@ -554,11 +554,11 @@ batch_uninstall_applications() { if [[ $failed_count -eq 1 ]]; then local first_reason=${failed_items[0]#*:} case "$first_reason" in - still*running*) reason_summary="is still running" ;; - remove*failed*) reason_summary="could not be removed" ;; - permission*denied*) reason_summary="permission denied" ;; - owned*by*) reason_summary="$first_reason (try with sudo)" ;; - *) reason_summary="$first_reason" ;; + still*running*) reason_summary="is still running" ;; + remove*failed*) reason_summary="could not be removed" ;; + permission*denied*) reason_summary="permission denied" ;; + owned*by*) reason_summary="$first_reason (try with sudo)" ;; + *) reason_summary="$first_reason" ;; esac fi summary_details+=("Failed: ${RED}${failed_list}${NC} ${reason_summary}") @@ -586,7 +586,7 @@ batch_uninstall_applications() { fi local autoremove_output removed_count - autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2>/dev/null) || true + autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2> /dev/null) || true removed_count=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" || true) removed_count=${removed_count:-0} @@ -604,7 +604,7 @@ batch_uninstall_applications() { if [[ $success_count -gt 0 ]]; then local -a removed_paths=() for detail in "${app_details[@]}"; do - IFS='|' read -r app_name app_path _ _ _ _ <<<"$detail" + IFS='|' read -r app_name app_path _ _ _ _ <<< "$detail" for success_name in "${success_items[@]}"; do if [[ "$success_name" == "$app_name" ]]; then removed_paths+=("$app_path") @@ -613,21 +613,21 @@ batch_uninstall_applications() { done done if [[ ${#removed_paths[@]} -gt 0 ]]; then - remove_apps_from_dock "${removed_paths[@]}" 2>/dev/null || true + remove_apps_from_dock "${removed_paths[@]}" 2> /dev/null || true fi fi # Clean up sudo keepalive if it was started. if [[ -n "${sudo_keepalive_pid:-}" ]]; then - kill "$sudo_keepalive_pid" 2>/dev/null || true - wait "$sudo_keepalive_pid" 2>/dev/null || true + kill "$sudo_keepalive_pid" 2> /dev/null || true + wait "$sudo_keepalive_pid" 2> /dev/null || true sudo_keepalive_pid="" fi # Invalidate cache if any apps were successfully uninstalled. if [[ $success_count -gt 0 ]]; then local cache_file="$HOME/.cache/mole/app_scan_cache" - rm -f "$cache_file" 2>/dev/null || true + rm -f "$cache_file" 2> /dev/null || true fi ((total_size_cleaned += total_size_freed)) diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index 2906e16..91f9627 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -18,13 +18,13 @@ resolve_path() { [[ -e "$p" ]] || return 1 # macOS 12.3+ and Linux have realpath - if realpath "$p" 2>/dev/null; then + if realpath "$p" 2> /dev/null; then return 0 fi # Fallback: use cd -P to resolve directory, then append basename local dir base - dir=$(cd -P "$(dirname "$p")" 2>/dev/null && pwd) || return 1 + dir=$(cd -P "$(dirname "$p")" 2> /dev/null && pwd) || return 1 base=$(basename "$p") echo "$dir/$base" } @@ -32,7 +32,7 @@ resolve_path() { # Check if Homebrew is installed and accessible # Returns: 0 if brew is available, 1 otherwise is_homebrew_available() { - command -v brew >/dev/null 2>&1 + command -v brew > /dev/null 2>&1 } # Extract cask token from a Caskroom path @@ -44,8 +44,8 @@ _extract_cask_token_from_path() { # Check if path is inside Caskroom case "$path" in - /opt/homebrew/Caskroom/* | /usr/local/Caskroom/*) ;; - *) return 1 ;; + /opt/homebrew/Caskroom/* | /usr/local/Caskroom/*) ;; + *) return 1 ;; esac # Extract token from path: /opt/homebrew/Caskroom///... @@ -87,9 +87,9 @@ _detect_cask_via_caskroom_search() { [[ -d "$room" ]] || continue while IFS= read -r match; do [[ -n "$match" ]] || continue - token=$(_extract_cask_token_from_path "$match" 2>/dev/null) || continue + token=$(_extract_cask_token_from_path "$match" 2> /dev/null) || continue [[ -n "$token" ]] && tokens+=("$token") - done < <(find "$room" -maxdepth 3 -name "$app_bundle_name" 2>/dev/null) + done < <(find "$room" -maxdepth 3 -name "$app_bundle_name" 2> /dev/null) done # Need at least one token @@ -101,7 +101,7 @@ _detect_cask_via_caskroom_search() { # Only succeed if exactly one unique token found and it's installed if ((${#uniq[@]} == 1)) && [[ -n "${uniq[0]}" ]]; then - HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2>/dev/null | grep -qxF "${uniq[0]}" || return 1 + HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "${uniq[0]}" || return 1 echo "${uniq[0]}" return 0 fi @@ -115,7 +115,7 @@ _detect_cask_via_symlink_check() { [[ -L "$app_path" ]] || return 1 local target - target=$(readlink "$app_path" 2>/dev/null) || return 1 + target=$(readlink "$app_path" 2> /dev/null) || return 1 _extract_cask_token_from_path "$target" } @@ -127,10 +127,10 @@ _detect_cask_via_brew_list() { app_name_lower=$(echo "${app_bundle_name%.app}" | LC_ALL=C tr '[:upper:]' '[:lower:]') local cask_name - cask_name=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2>/dev/null | grep -Fix "$app_name_lower") || return 1 + cask_name=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -Fix "$app_name_lower") || return 1 # Verify this cask actually owns this app path - HOMEBREW_NO_ENV_HINTS=1 brew info --cask "$cask_name" 2>/dev/null | grep -qF "$app_path" || return 1 + HOMEBREW_NO_ENV_HINTS=1 brew info --cask "$cask_name" 2> /dev/null | grep -qF "$app_path" || return 1 echo "$cask_name" } @@ -175,7 +175,7 @@ brew_uninstall_cask() { # Ensure we have sudo access if needed, to prevent brew from hanging on password prompt if [[ "${NONINTERACTIVE:-}" != "1" && -t 0 && -t 1 ]]; then - if ! sudo -n true 2>/dev/null; then + if ! sudo -n true 2> /dev/null; then sudo -v fi fi @@ -194,7 +194,7 @@ brew_uninstall_cask() { # Verify removal local cask_gone=true app_gone=true - HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2>/dev/null | grep -qxF "$cask_name" && cask_gone=false + HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "$cask_name" && cask_gone=false [[ -n "$app_path" && -e "$app_path" ]] && app_gone=false # Success: uninstall worked and both are gone, or already uninstalled From bacc782e419cd1d015ff04426c63bae1d70a8ef7 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 17 Jan 2026 09:58:08 +0800 Subject: [PATCH 08/91] ShellCheck SC2001 Warning --- .github/workflows/test.yml | 2 +- lib/core/app_protection.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb5a23c..dd53659 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -79,7 +79,7 @@ jobs: echo "Checking for hardcoded secrets..." matches=$(grep -r "password\|secret\|api_key" --include="*.sh" . \ | grep -v "# \|test" \ - | grep -v -E "lib/core/sudo\.sh|lib/core/app_protection\.sh|lib/clean/user\.sh|lib/clean/brew\.sh|bin/optimize\.sh|lib/clean/apps\.sh" || true) + | grep -v -E "lib/core/sudo\.sh|lib/core/app_protection\.sh|lib/clean/user\.sh|lib/clean/brew\.sh|bin/optimize\.sh|lib/clean/apps\.sh|lib/uninstall/batch\.sh" || true) if [[ -n "$matches" ]]; then echo "$matches" echo "✗ Potential secrets found" diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 05955cd..5cc1d4d 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -956,7 +956,7 @@ find_app_receipt_files() { fi # Normalize path (remove duplicate slashes) - clean_path=$(echo "$clean_path" | sed 's#//*#/#g') + clean_path=$(tr -s "/" <<<"$clean_path") # ------------------------------------------------------------------------ # Safety check: restrict removal to trusted paths From b9072c2389b9525c4453909065a6cc03e8c57b4f Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 17 Jan 2026 01:59:47 +0000 Subject: [PATCH 09/91] chore: auto format code --- lib/core/app_protection.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 5a49ad4..bcd1d97 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -956,7 +956,7 @@ find_app_receipt_files() { fi # Normalize path (remove duplicate slashes) - clean_path=$(tr -s "/" <<<"$clean_path") + clean_path=$(tr -s "/" <<< "$clean_path") # ------------------------------------------------------------------------ # Safety check: restrict removal to trusted paths From e6fc0613d5ac16fbc090784dfac1bd455bb28e09 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 17 Jan 2026 10:12:23 +0800 Subject: [PATCH 10/91] perf: improve cleanup UI responsiveness and reduce visual flicker - Speed up spinner animation from 100ms to 50ms for smoother visuals - Fix spinner flicker by deferring stop until output is ready - Remove unnecessary 'Preparing...' spinner at section start - Hide whitelist-protected items from output (Trash, Finder metadata) - Add spinner feedback for system diagnostic log cleanup - Remove redundant stop_section_spinner calls in cleanup modules The cleanup process now feels significantly faster and more polished, with continuous visual feedback and no jarring gaps between operations. --- bin/clean.sh | 93 ++++++++++---------- lib/clean/system.sh | 74 ++++++++-------- lib/clean/user.sh | 75 +++++++--------- lib/core/ui.sh | 202 ++++++++++++++++++++++---------------------- 4 files changed, 216 insertions(+), 228 deletions(-) diff --git a/bin/clean.sh b/bin/clean.sh index 2de8f28..3a33ed6 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -69,10 +69,10 @@ if [[ -f "$HOME/.config/mole/whitelist" ]]; then fi case "$line" in - / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /etc | /etc/* | /var/db | /var/db/*) - WHITELIST_WARNINGS+=("Protected system path: $line") - continue - ;; + / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /etc | /etc/* | /var/db | /var/db/*) + WHITELIST_WARNINGS+=("Protected system path: $line") + continue + ;; esac duplicate="false" @@ -86,7 +86,7 @@ if [[ -f "$HOME/.config/mole/whitelist" ]]; then fi [[ "$duplicate" == "true" ]] && continue WHITELIST_PATTERNS+=("$line") - done < "$HOME/.config/mole/whitelist" + done <"$HOME/.config/mole/whitelist" else WHITELIST_PATTERNS=("${DEFAULT_WHITELIST_PATTERNS[@]}") fi @@ -140,7 +140,7 @@ cleanup() { fi CLEANUP_DONE=true - stop_inline_spinner 2> /dev/null || true + stop_inline_spinner 2>/dev/null || true if [[ -t 1 ]]; then printf "\r\033[K" >&2 || true @@ -164,14 +164,10 @@ start_section() { echo "" echo -e "${PURPLE_BOLD}${ICON_ARROW} $1${NC}" - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Preparing..." - fi - if [[ "$DRY_RUN" == "true" ]]; then ensure_user_file "$EXPORT_LIST_FILE" - echo "" >> "$EXPORT_LIST_FILE" - echo "=== $1 ===" >> "$EXPORT_LIST_FILE" + echo "" >>"$EXPORT_LIST_FILE" + echo "=== $1 ===" >>"$EXPORT_LIST_FILE" fi } @@ -224,7 +220,7 @@ normalize_paths_for_cleanup() { done fi [[ "$is_child" == "true" ]] || result_paths+=("$path") - done <<< "$sorted_paths" + done <<<"$sorted_paths" if [[ ${#result_paths[@]} -gt 0 ]]; then printf '%s\n' "${result_paths[@]}" @@ -236,9 +232,9 @@ get_cleanup_path_size_kb() { local path="$1" if [[ -f "$path" && ! -L "$path" ]]; then - if command -v stat > /dev/null 2>&1; then + if command -v stat >/dev/null 2>&1; then local bytes - bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") + bytes=$(stat -f%z "$path" 2>/dev/null || echo "0") if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then echo $(((bytes + 1023) / 1024)) return 0 @@ -247,9 +243,9 @@ get_cleanup_path_size_kb() { fi if [[ -L "$path" ]]; then - if command -v stat > /dev/null 2>&1; then + if command -v stat >/dev/null 2>&1; then local bytes - bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") + bytes=$(stat -f%z "$path" 2>/dev/null || echo "0") if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then echo $(((bytes + 1023) / 1024)) else @@ -308,9 +304,6 @@ safe_clean() { return 0 fi - # Always stop spinner before outputting results - stop_section_spinner - local description local -a targets @@ -361,6 +354,7 @@ safe_clean() { local show_scan_feedback=false if [[ ${#targets[@]} -gt 20 && -t 1 ]]; then show_scan_feedback=true + stop_section_spinner MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning ${#targets[@]} items..." fi @@ -467,9 +461,9 @@ safe_clean() { [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 if [[ "$size" -gt 0 ]]; then - echo "$size 1" > "$temp_dir/result_${idx}" + echo "$size 1" >"$temp_dir/result_${idx}" else - echo "0 0" > "$temp_dir/result_${idx}" + echo "0 0" >"$temp_dir/result_${idx}" fi ((idx++)) @@ -494,17 +488,17 @@ safe_clean() { [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 local tmp_file="$temp_dir/result_${idx}.$$" if [[ "$size" -gt 0 ]]; then - echo "$size 1" > "$tmp_file" + echo "$size 1" >"$tmp_file" else - echo "0 0" > "$tmp_file" + echo "0 0" >"$tmp_file" fi - mv "$tmp_file" "$temp_dir/result_${idx}" 2> /dev/null || true + mv "$tmp_file" "$temp_dir/result_${idx}" 2>/dev/null || true ) & pids+=($!) ((idx++)) if ((${#pids[@]} >= MOLE_MAX_PARALLEL_JOBS)); then - wait "${pids[0]}" 2> /dev/null || true + wait "${pids[0]}" 2>/dev/null || true pids=("${pids[@]:1}") ((completed++)) @@ -517,7 +511,7 @@ safe_clean() { if [[ ${#pids[@]} -gt 0 ]]; then for pid in "${pids[@]}"; do - wait "$pid" 2> /dev/null || true + wait "$pid" 2>/dev/null || true ((completed++)) if [[ "$show_spinner" == "true" && -t 1 ]]; then @@ -533,11 +527,11 @@ safe_clean() { for path in "${existing_paths[@]}"; do local result_file="$temp_dir/result_${idx}" if [[ -f "$result_file" ]]; then - read -r size count < "$result_file" 2> /dev/null || true + read -r size count <"$result_file" 2>/dev/null || true local removed=0 if [[ "$DRY_RUN" != "true" ]]; then if [[ -L "$path" ]]; then - rm "$path" 2> /dev/null && removed=1 + rm "$path" 2>/dev/null && removed=1 else if safe_remove "$path" true; then removed=1 @@ -574,7 +568,7 @@ safe_clean() { local removed=0 if [[ "$DRY_RUN" != "true" ]]; then if [[ -L "$path" ]]; then - rm "$path" 2> /dev/null && removed=1 + rm "$path" 2>/dev/null && removed=1 else if safe_remove "$path" true; then removed=1 @@ -614,6 +608,9 @@ safe_clean() { fi if [[ $removed_any -eq 1 ]]; then + # Stop spinner before output + stop_section_spinner + local size_human=$(bytes_to_human "$((total_size_kb * 1024))") local label="$description" @@ -632,9 +629,9 @@ safe_clean() { local size=0 if [[ -n "${temp_dir:-}" && -f "$temp_dir/result_${idx}" ]]; then - read -r size count < "$temp_dir/result_${idx}" 2> /dev/null || true + read -r size count <"$temp_dir/result_${idx}" 2>/dev/null || true else - size=$(get_cleanup_path_size_kb "$path" 2> /dev/null || echo "0") + size=$(get_cleanup_path_size_kb "$path" 2>/dev/null || echo "0") fi [[ "$size" == "0" || -z "$size" ]] && { @@ -642,7 +639,7 @@ safe_clean() { continue } - echo "$(dirname "$path")|$size|$path" >> "$paths_temp" + echo "$(dirname "$path")|$size|$path" >>"$paths_temp" ((idx++)) done fi @@ -673,9 +670,9 @@ safe_clean() { ' | while IFS='|' read -r display_path total_size child_count; do local size_human=$(bytes_to_human "$((total_size * 1024))") if [[ $child_count -gt 1 ]]; then - echo "$display_path # $size_human ($child_count items)" >> "$EXPORT_LIST_FILE" + echo "$display_path # $size_human ($child_count items)" >>"$EXPORT_LIST_FILE" else - echo "$display_path # $size_human" >> "$EXPORT_LIST_FILE" + echo "$display_path # $size_human" >>"$EXPORT_LIST_FILE" fi done @@ -711,7 +708,7 @@ start_cleanup() { SYSTEM_CLEAN=false ensure_user_file "$EXPORT_LIST_FILE" - cat > "$EXPORT_LIST_FILE" << EOF + cat >"$EXPORT_LIST_FILE" <> "$EXPORT_LIST_FILE" + } >>"$EXPORT_LIST_FILE" summary_details+=("Detailed file list: ${GRAY}$EXPORT_LIST_FILE${NC}") summary_details+=("Use ${GRAY}mo clean --whitelist${NC} to add protection rules") @@ -1050,17 +1047,17 @@ perform_cleanup() { main() { for arg in "$@"; do case "$arg" in - "--debug") - export MO_DEBUG=1 - ;; - "--dry-run" | "-n") - DRY_RUN=true - ;; - "--whitelist") - source "$SCRIPT_DIR/../lib/manage/whitelist.sh" - manage_whitelist "clean" - exit 0 - ;; + "--debug") + export MO_DEBUG=1 + ;; + "--dry-run" | "-n") + DRY_RUN=true + ;; + "--whitelist") + source "$SCRIPT_DIR/../lib/manage/whitelist.sh" + manage_whitelist "clean" + exit 0 + ;; esac done diff --git a/lib/clean/system.sh b/lib/clean/system.sh index 27d6ab0..aa1ce4a 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -27,14 +27,14 @@ clean_deep_system() { continue fi local item_flags - item_flags=$($STAT_BSD -f%Sf "$item" 2> /dev/null || echo "") + item_flags=$($STAT_BSD -f%Sf "$item" 2>/dev/null || echo "") if [[ "$item_flags" == *"restricted"* ]]; then continue fi if safe_sudo_remove "$item"; then ((updates_cleaned++)) fi - done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) + done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2>/dev/null || true) [[ $updates_cleaned -gt 0 ]] && log_success "System library updates" fi fi @@ -76,28 +76,32 @@ clean_deep_system() { last_update_time=$current_time fi fi - done < <(run_with_timeout 5 command find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true) + done < <(run_with_timeout 5 command find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2>/dev/null || true) stop_section_spinner [[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches ($code_sign_cleaned items)" - safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*" "$MOLE_LOG_AGE_DAYS" "f" || true - safe_sudo_find_delete "/private/var/db/diagnostics/Persist" "*" "$MOLE_LOG_AGE_DAYS" "f" || true - safe_sudo_find_delete "/private/var/db/DiagnosticPipeline" "*" "$MOLE_LOG_AGE_DAYS" "f" || true - log_success "System diagnostic logs" - safe_sudo_find_delete "/private/var/db/powerlog" "*" "$MOLE_LOG_AGE_DAYS" "f" || true - log_success "Power logs" - safe_sudo_find_delete "/private/var/db/reportmemoryexception/MemoryLimitViolations" "*" "30" "f" || true - log_success "Memory exception reports" - start_section_spinner "Cleaning diagnostic trace logs..." - local diag_logs_cleaned=0 - safe_sudo_find_delete "/private/var/db/diagnostics/Persist" "*.tracev3" "30" "f" && diag_logs_cleaned=1 || true - safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*.tracev3" "30" "f" && diag_logs_cleaned=1 || true + + start_section_spinner "Cleaning system diagnostic logs..." + local diag_cleaned=0 + safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*" "$MOLE_LOG_AGE_DAYS" "f" && diag_cleaned=1 || true + safe_sudo_find_delete "/private/var/db/diagnostics/Persist" "*" "$MOLE_LOG_AGE_DAYS" "f" && diag_cleaned=1 || true + safe_sudo_find_delete "/private/var/db/DiagnosticPipeline" "*" "$MOLE_LOG_AGE_DAYS" "f" && diag_cleaned=1 || true + safe_sudo_find_delete "/private/var/db/powerlog" "*" "$MOLE_LOG_AGE_DAYS" "f" && diag_cleaned=1 || true + safe_sudo_find_delete "/private/var/db/reportmemoryexception/MemoryLimitViolations" "*" "30" "f" && diag_cleaned=1 || true stop_section_spinner - [[ $diag_logs_cleaned -eq 1 ]] && log_success "System diagnostic trace logs" + + [[ $diag_cleaned -eq 1 ]] && log_success "System diagnostic logs" + + start_section_spinner "Cleaning diagnostic trace logs..." + local trace_cleaned=0 + safe_sudo_find_delete "/private/var/db/diagnostics/Persist" "*.tracev3" "30" "f" && trace_cleaned=1 || true + safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*.tracev3" "30" "f" && trace_cleaned=1 || true + stop_section_spinner + [[ $trace_cleaned -eq 1 ]] && log_success "System diagnostic trace logs" } # Incomplete Time Machine backups. clean_time_machine_failed_backups() { local tm_cleaned=0 - if ! command -v tmutil > /dev/null 2>&1; then + if ! command -v tmutil >/dev/null 2>&1; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" return 0 fi @@ -151,9 +155,9 @@ clean_time_machine_failed_backups() { fi for volume in "${backup_volumes[@]}"; do local fs_type - fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "unknown") + fs_type=$(run_with_timeout 1 command df -T "$volume" 2>/dev/null | tail -1 | awk '{print $2}' || echo "unknown") case "$fs_type" in - nfs | smbfs | afpfs | cifs | webdav | unknown) continue ;; + nfs | smbfs | afpfs | cifs | webdav | unknown) continue ;; esac local backupdb_dir="$volume/Backups.backupdb" if [[ -d "$backupdb_dir" ]]; then @@ -181,11 +185,11 @@ clean_time_machine_failed_backups() { note_activity continue fi - if ! command -v tmutil > /dev/null 2>&1; then + if ! command -v tmutil >/dev/null 2>&1; then echo -e " ${YELLOW}!${NC} tmutil not available, skipping: $backup_name" continue fi - if tmutil delete "$inprogress_file" 2> /dev/null; then + if tmutil delete "$inprogress_file" 2>/dev/null; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name ${GREEN}($size_human)${NC}" ((tm_cleaned++)) ((files_cleaned++)) @@ -195,14 +199,14 @@ clean_time_machine_failed_backups() { else echo -e " ${YELLOW}!${NC} Could not delete: $backup_name · try manually with sudo" fi - done < <(run_with_timeout 15 find "$backupdb_dir" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) + done < <(run_with_timeout 15 find "$backupdb_dir" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2>/dev/null || true) fi # APFS bundles. for bundle in "$volume"/*.backupbundle "$volume"/*.sparsebundle; do [[ -e "$bundle" ]] || continue [[ -d "$bundle" ]] || continue local bundle_name=$(basename "$bundle") - local mounted_path=$(hdiutil info 2> /dev/null | grep -A 5 "image-path.*$bundle_name" | grep "/Volumes/" | awk '{print $1}' | head -1 || echo "") + local mounted_path=$(hdiutil info 2>/dev/null | grep -A 5 "image-path.*$bundle_name" | grep "/Volumes/" | awk '{print $1}' | head -1 || echo "") if [[ -n "$mounted_path" && -d "$mounted_path" ]]; then while IFS= read -r inprogress_file; do [[ -d "$inprogress_file" ]] || continue @@ -227,10 +231,10 @@ clean_time_machine_failed_backups() { note_activity continue fi - if ! command -v tmutil > /dev/null 2>&1; then + if ! command -v tmutil >/dev/null 2>&1; then continue fi - if tmutil delete "$inprogress_file" 2> /dev/null; then + if tmutil delete "$inprogress_file" 2>/dev/null; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name ${GREEN}($size_human)${NC}" ((tm_cleaned++)) ((files_cleaned++)) @@ -240,7 +244,7 @@ clean_time_machine_failed_backups() { else echo -e " ${YELLOW}!${NC} Could not delete from bundle: $backup_name" fi - done < <(run_with_timeout 15 find "$mounted_path" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) + done < <(run_with_timeout 15 find "$mounted_path" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2>/dev/null || true) fi done done @@ -256,20 +260,20 @@ clean_time_machine_failed_backups() { # Returns 2 if status cannot be determined tm_is_running() { local st - st="$(tmutil status 2> /dev/null)" || return 2 + st="$(tmutil status 2>/dev/null)" || return 2 # If we can't find a Running field at all, treat as unknown. - if ! grep -qE '(^|[[:space:]])("Running"|Running)[[:space:]]*=' <<< "$st"; then + if ! grep -qE '(^|[[:space:]])("Running"|Running)[[:space:]]*=' <<<"$st"; then return 2 fi # Match: Running = 1; OR "Running" = 1 (with or without trailing ;) - grep -qE '(^|[[:space:]])("Running"|Running)[[:space:]]*=[[:space:]]*1([[:space:]]*;|$)' <<< "$st" + grep -qE '(^|[[:space:]])("Running"|Running)[[:space:]]*=[[:space:]]*1([[:space:]]*;|$)' <<<"$st" } # Local APFS snapshots (keep the most recent). clean_local_snapshots() { - if ! command -v tmutil > /dev/null 2>&1; then + if ! command -v tmutil >/dev/null 2>&1; then return 0 fi @@ -288,7 +292,7 @@ clean_local_snapshots() { start_section_spinner "Checking local snapshots..." local snapshot_list - snapshot_list=$(tmutil listlocalsnapshots / 2> /dev/null) + snapshot_list=$(tmutil listlocalsnapshots / 2>/dev/null) stop_section_spinner [[ -z "$snapshot_list" ]] && return 0 local cleaned_count=0 @@ -301,14 +305,14 @@ clean_local_snapshots() { local snap_name="${BASH_REMATCH[0]}" snapshots+=("$snap_name") local date_str="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]:0:2}:${BASH_REMATCH[4]:2:2}:${BASH_REMATCH[4]:4:2}" - local snap_ts=$(date -j -f "%Y-%m-%d %H:%M:%S" "$date_str" "+%s" 2> /dev/null || echo "0") + local snap_ts=$(date -j -f "%Y-%m-%d %H:%M:%S" "$date_str" "+%s" 2>/dev/null || echo "0") [[ "$snap_ts" == "0" ]] && continue if [[ "$snap_ts" -gt "$newest_ts" ]]; then newest_ts="$snap_ts" newest_name="$snap_name" fi fi - done <<< "$snapshot_list" + done <<<"$snapshot_list" [[ ${#snapshots[@]} -eq 0 ]] && return 0 [[ -z "$newest_name" ]] && return 0 @@ -327,7 +331,7 @@ clean_local_snapshots() { echo -e " ${GRAY}The most recent snapshot will be kept.${NC}" echo -ne " ${PURPLE}${ICON_ARROW}${NC} Remove all local snapshots except the most recent one? ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: " local choice - if type read_key > /dev/null 2>&1; then + if type read_key >/dev/null 2>&1; then choice=$(read_key) else IFS= read -r -s -n 1 choice || choice="" @@ -352,7 +356,7 @@ clean_local_snapshots() { ((cleaned_count++)) note_activity else - if sudo tmutil deletelocalsnapshots "${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]}-${BASH_REMATCH[4]}" > /dev/null 2>&1; then + if sudo tmutil deletelocalsnapshots "${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]}-${BASH_REMATCH[4]}" >/dev/null 2>&1; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed snapshot: $snap_name" ((cleaned_count++)) note_activity diff --git a/lib/clean/user.sh b/lib/clean/user.sh index a951685..7c82b5c 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -7,10 +7,7 @@ clean_user_essentials() { stop_section_spinner safe_clean ~/Library/Logs/* "User app logs" - if is_path_whitelisted "$HOME/.Trash"; then - note_activity - echo -e " ${GREEN}${ICON_EMPTY}${NC} Trash · whitelist protected" - else + if ! is_path_whitelisted "$HOME/.Trash"; then safe_clean ~/.Trash/* "Trash" fi } @@ -23,7 +20,7 @@ clean_chrome_old_versions() { ) # Match the exact Chrome process name to avoid false positives - if pgrep -x "Google Chrome" > /dev/null 2>&1; then + if pgrep -x "Google Chrome" >/dev/null 2>&1; then echo -e " ${YELLOW}${ICON_WARNING}${NC} Google Chrome running · old versions cleanup skipped" return 0 fi @@ -42,7 +39,7 @@ clean_chrome_old_versions() { [[ -L "$current_link" ]] || continue local current_version - current_version=$(readlink "$current_link" 2> /dev/null || true) + current_version=$(readlink "$current_link" 2>/dev/null || true) current_version="${current_version##*/}" [[ -n "$current_version" ]] || continue @@ -72,9 +69,9 @@ clean_chrome_old_versions() { cleaned_any=true if [[ "$DRY_RUN" != "true" ]]; then if has_sudo_session; then - safe_sudo_remove "$dir" > /dev/null 2>&1 || true + safe_sudo_remove "$dir" >/dev/null 2>&1 || true else - safe_remove "$dir" true > /dev/null 2>&1 || true + safe_remove "$dir" true >/dev/null 2>&1 || true fi fi done @@ -103,7 +100,7 @@ clean_edge_old_versions() { ) # Match the exact Edge process name to avoid false positives (e.g., Microsoft Teams) - if pgrep -x "Microsoft Edge" > /dev/null 2>&1; then + if pgrep -x "Microsoft Edge" >/dev/null 2>&1; then echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · old versions cleanup skipped" return 0 fi @@ -122,7 +119,7 @@ clean_edge_old_versions() { [[ -L "$current_link" ]] || continue local current_version - current_version=$(readlink "$current_link" 2> /dev/null || true) + current_version=$(readlink "$current_link" 2>/dev/null || true) current_version="${current_version##*/}" [[ -n "$current_version" ]] || continue @@ -152,9 +149,9 @@ clean_edge_old_versions() { cleaned_any=true if [[ "$DRY_RUN" != "true" ]]; then if has_sudo_session; then - safe_sudo_remove "$dir" > /dev/null 2>&1 || true + safe_sudo_remove "$dir" >/dev/null 2>&1 || true else - safe_remove "$dir" true > /dev/null 2>&1 || true + safe_remove "$dir" true >/dev/null 2>&1 || true fi fi done @@ -180,7 +177,7 @@ clean_edge_updater_old_versions() { local updater_dir="$HOME/Library/Application Support/Microsoft/EdgeUpdater/apps/msedge-stable" [[ -d "$updater_dir" ]] || return 0 - if pgrep -x "Microsoft Edge" > /dev/null 2>&1; then + if pgrep -x "Microsoft Edge" >/dev/null 2>&1; then echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · updater cleanup skipped" return 0 fi @@ -218,7 +215,7 @@ clean_edge_updater_old_versions() { ((cleaned_count++)) cleaned_any=true if [[ "$DRY_RUN" != "true" ]]; then - safe_remove "$dir" true > /dev/null 2>&1 || true + safe_remove "$dir" true >/dev/null 2>&1 || true fi done @@ -245,20 +242,20 @@ scan_external_volumes() { [[ -d "$volume" && -w "$volume" && ! -L "$volume" ]] || continue [[ "$volume" == "/" || "$volume" == "/Volumes/Macintosh HD" ]] && continue local protocol="" - protocol=$(run_with_timeout 1 command diskutil info "$volume" 2> /dev/null | grep -i "Protocol:" | awk '{print $2}' || echo "") + protocol=$(run_with_timeout 1 command diskutil info "$volume" 2>/dev/null | grep -i "Protocol:" | awk '{print $2}' || echo "") case "$protocol" in - SMB | NFS | AFP | CIFS | WebDAV) - network_volumes+=("$volume") - continue - ;; + SMB | NFS | AFP | CIFS | WebDAV) + network_volumes+=("$volume") + continue + ;; esac local fs_type="" - fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "") + fs_type=$(run_with_timeout 1 command df -T "$volume" 2>/dev/null | tail -1 | awk '{print $2}' || echo "") case "$fs_type" in - nfs | smbfs | afpfs | cifs | webdav) - network_volumes+=("$volume") - continue - ;; + nfs | smbfs | afpfs | cifs | webdav) + network_volumes+=("$volume") + continue + ;; esac candidate_volumes+=("$volume") done @@ -278,7 +275,7 @@ scan_external_volumes() { if [[ -d "$volume_trash" && "$DRY_RUN" != "true" ]] && ! is_path_whitelisted "$volume_trash"; then while IFS= read -r -d '' item; do safe_remove "$item" true || true - done < <(command find "$volume_trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) + done < <(command find "$volume_trash" -mindepth 1 -maxdepth 1 -print0 2>/dev/null || true) fi if [[ "$PROTECT_FINDER_METADATA" != "true" ]]; then clean_ds_store_tree "$volume" "$(basename "$volume") volume (.DS_Store)" @@ -288,17 +285,13 @@ scan_external_volumes() { } # Finder metadata (.DS_Store). clean_finder_metadata() { - stop_section_spinner if [[ "$PROTECT_FINDER_METADATA" == "true" ]]; then - note_activity - echo -e " ${GREEN}${ICON_EMPTY}${NC} Finder metadata · whitelist protected" return fi clean_ds_store_tree "$HOME" "Home directory (.DS_Store)" } # macOS system caches and user-level leftovers. clean_macos_system_caches() { - stop_section_spinner # safe_clean already checks protected paths. safe_clean ~/Library/Saved\ Application\ State/* "Saved application states" || true safe_clean ~/Library/Caches/com.apple.photoanalysisd "Photo analysis cache" || true @@ -318,7 +311,6 @@ clean_macos_system_caches() { safe_clean ~/Library/Application\ Support/AddressBook/Sources/*/Photos.cache "Address Book photo cache" || true } clean_recent_items() { - stop_section_spinner local shared_dir="$HOME/Library/Application Support/com.apple.sharedfilelist" local -a recent_lists=( "$shared_dir/com.apple.LSSharedFileList.RecentApplications.sfl2" @@ -338,7 +330,6 @@ clean_recent_items() { safe_clean ~/Library/Preferences/com.apple.recentitems.plist "Recent items preferences" || true } clean_mail_downloads() { - stop_section_spinner local mail_age_days=${MOLE_MAIL_AGE_DAYS:-} if ! [[ "$mail_age_days" =~ ^[0-9]+$ ]]; then mail_age_days=30 @@ -371,7 +362,7 @@ clean_mail_downloads() { ((cleaned_kb += file_size_kb)) fi fi - done < <(command find "$target_path" -type f -mtime +"$mail_age_days" -print0 2> /dev/null || true) + done < <(command find "$target_path" -type f -mtime +"$mail_age_days" -print0 2>/dev/null || true) fi done if [[ $count -gt 0 ]]; then @@ -429,7 +420,7 @@ process_container_cache() { local cache_dir="$container_dir/Data/Library/Caches" [[ -d "$cache_dir" ]] || return 0 # Fast non-empty check. - if find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then + if find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then local size=$(get_path_size_kb "$cache_dir") ((total_size += size)) found_any=true @@ -449,7 +440,6 @@ process_container_cache() { } # Browser caches (Safari/Chrome/Edge/Firefox). clean_browsers() { - stop_section_spinner safe_clean ~/Library/Caches/com.apple.Safari/* "Safari cache" # Chrome/Chromium. safe_clean ~/Library/Caches/Google/Chrome/* "Chrome cache" @@ -461,7 +451,7 @@ clean_browsers() { safe_clean ~/Library/Caches/company.thebrowser.dia/* "Dia cache" safe_clean ~/Library/Caches/BraveSoftware/Brave-Browser/* "Brave cache" local firefox_running=false - if pgrep -x "Firefox" > /dev/null 2>&1; then + if pgrep -x "Firefox" >/dev/null 2>&1; then firefox_running=true fi if [[ "$firefox_running" == "true" ]]; then @@ -485,7 +475,6 @@ clean_browsers() { } # Cloud storage caches. clean_cloud_storage() { - stop_section_spinner safe_clean ~/Library/Caches/com.dropbox.* "Dropbox cache" safe_clean ~/Library/Caches/com.getdropbox.dropbox "Dropbox cache" safe_clean ~/Library/Caches/com.google.GoogleDrive "Google Drive cache" @@ -496,7 +485,6 @@ clean_cloud_storage() { } # Office app caches. clean_office_applications() { - stop_section_spinner safe_clean ~/Library/Caches/com.microsoft.Word "Microsoft Word cache" safe_clean ~/Library/Caches/com.microsoft.Excel "Microsoft Excel cache" safe_clean ~/Library/Caches/com.microsoft.Powerpoint "Microsoft PowerPoint cache" @@ -516,8 +504,7 @@ clean_virtualization_tools() { } # Application Support logs/caches. clean_application_support_logs() { - stop_section_spinner - if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" > /dev/null 2>&1; then + if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" >/dev/null 2>&1; then note_activity echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped: No permission to access Application Support" return 0 @@ -549,7 +536,7 @@ clean_application_support_logs() { local -a start_candidates=("$app_dir/log" "$app_dir/logs" "$app_dir/activitylog" "$app_dir/Cache/Cache_Data" "$app_dir/Crashpad/completed") for candidate in "${start_candidates[@]}"; do if [[ -d "$candidate" ]]; then - if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then + if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then local size=$(get_path_size_kb "$candidate") ((total_size += size)) ((cleaned_count++)) @@ -557,7 +544,7 @@ clean_application_support_logs() { if [[ "$DRY_RUN" != "true" ]]; then for item in "$candidate"/*; do [[ -e "$item" ]] || continue - safe_remove "$item" true > /dev/null 2>&1 || true + safe_remove "$item" true >/dev/null 2>&1 || true done fi fi @@ -573,7 +560,7 @@ clean_application_support_logs() { local -a gc_candidates=("$container_path/Logs" "$container_path/Library/Logs") for candidate in "${gc_candidates[@]}"; do if [[ -d "$candidate" ]]; then - if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then + if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then local size=$(get_path_size_kb "$candidate") ((total_size += size)) ((cleaned_count++)) @@ -581,7 +568,7 @@ clean_application_support_logs() { if [[ "$DRY_RUN" != "true" ]]; then for item in "$candidate"/*; do [[ -e "$item" ]] || continue - safe_remove "$item" true > /dev/null 2>&1 || true + safe_remove "$item" true >/dev/null 2>&1 || true done fi fi @@ -610,7 +597,7 @@ check_ios_device_backups() { if [[ -d "$backup_dir" ]]; then local backup_kb=$(get_path_size_kb "$backup_dir") if [[ -n "${backup_kb:-}" && "$backup_kb" -gt 102400 ]]; then - local backup_human=$(command du -sh "$backup_dir" 2> /dev/null | awk '{print $1}') + local backup_human=$(command du -sh "$backup_dir" 2>/dev/null | awk '{print $1}') if [[ -n "$backup_human" ]]; then note_activity echo -e " Found ${GREEN}${backup_human}${NC} iOS backups" diff --git a/lib/core/ui.sh b/lib/core/ui.sh index d27f231..257b385 100755 --- a/lib/core/ui.sh +++ b/lib/core/ui.sh @@ -168,47 +168,47 @@ read_key() { return 0 } case "$key" in - $'\n' | $'\r') echo "ENTER" ;; - $'\x7f' | $'\x08') echo "DELETE" ;; - $'\x1b') - # Check if this is an escape sequence (arrow keys) or ESC key - if IFS= read -r -s -n 1 -t 0.1 rest 2> /dev/null; then - if [[ "$rest" == "[" ]]; then - if IFS= read -r -s -n 1 -t 0.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 0.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 0.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 - # Not an escape sequence, it's ESC key - echo "QUIT" - fi + $'\n' | $'\r') echo "ENTER" ;; + $'\x7f' | $'\x08') echo "DELETE" ;; + $'\x1b') + # Check if this is an escape sequence (arrow keys) or ESC key + if IFS= read -r -s -n 1 -t 0.1 rest 2>/dev/null; then + if [[ "$rest" == "[" ]]; then + if IFS= read -r -s -n 1 -t 0.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 0.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 0.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 - # No following characters, it's ESC key + # Not an escape sequence, it's ESC key echo "QUIT" fi - ;; - ' ') echo "SPACE" ;; # Allow space in filter mode for selection - [[:print:]]) echo "CHAR:$key" ;; - *) echo "OTHER" ;; + else + # No following characters, it's ESC key + echo "QUIT" + fi + ;; + ' ') echo "SPACE" ;; # Allow space in filter mode for selection + [[:print:]]) echo "CHAR:$key" ;; + *) echo "OTHER" ;; esac return 0 fi @@ -218,53 +218,53 @@ read_key() { return 0 } case "$key" in - $'\n' | $'\r') echo "ENTER" ;; - ' ') echo "SPACE" ;; - '/') echo "FILTER" ;; - 'q' | 'Q') echo "QUIT" ;; - 'R') echo "RETRY" ;; - 'm' | 'M') echo "MORE" ;; - 'u' | 'U') echo "UPDATE" ;; - 't' | 'T') echo "TOUCHID" ;; - 'j' | 'J') echo "DOWN" ;; - 'k' | 'K') echo "UP" ;; - 'h' | 'H') echo "LEFT" ;; - 'l' | 'L') echo "RIGHT" ;; - $'\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 + $'\n' | $'\r') echo "ENTER" ;; + ' ') echo "SPACE" ;; + '/') echo "FILTER" ;; + 'q' | 'Q') echo "QUIT" ;; + 'R') echo "RETRY" ;; + 'm' | 'M') echo "MORE" ;; + 'u' | 'U') echo "UPDATE" ;; + 't' | 'T') echo "TOUCHID" ;; + 'j' | 'J') echo "DOWN" ;; + 'k' | 'K') echo "UP" ;; + 'h' | 'H') echo "LEFT" ;; + 'l' | 'L') echo "RIGHT" ;; + $'\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 "QUIT"; fi - ;; - [[:print:]]) echo "CHAR:$key" ;; - *) echo "OTHER" ;; + 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 + while IFS= read -r -s -n 1 -t 0.01 _ 2>/dev/null; do ((drained++)) [[ $drained -gt 100 ]] && break done @@ -288,7 +288,7 @@ INLINE_SPINNER_PID="" INLINE_SPINNER_STOP_FILE="" start_inline_spinner() { - stop_inline_spinner 2> /dev/null || true + stop_inline_spinner 2>/dev/null || true local message="$1" if [[ -t 1 ]]; then @@ -308,15 +308,15 @@ start_inline_spinner() { # Output to stderr to avoid interfering with stdout printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$message" >&2 || break ((i++)) - sleep 0.1 + sleep 0.05 done # Clean up stop file before exiting - rm -f "$stop_file" 2> /dev/null || true + rm -f "$stop_file" 2>/dev/null || true exit 0 ) & INLINE_SPINNER_PID=$! - disown 2> /dev/null || true + disown 2>/dev/null || true else echo -n " ${BLUE}|${NC} $message" >&2 || true fi @@ -326,25 +326,25 @@ stop_inline_spinner() { if [[ -n "$INLINE_SPINNER_PID" ]]; then # Cooperative stop: create stop file to signal spinner to exit if [[ -n "$INLINE_SPINNER_STOP_FILE" ]]; then - touch "$INLINE_SPINNER_STOP_FILE" 2> /dev/null || true + touch "$INLINE_SPINNER_STOP_FILE" 2>/dev/null || true fi # Wait briefly for cooperative exit local wait_count=0 - while kill -0 "$INLINE_SPINNER_PID" 2> /dev/null && [[ $wait_count -lt 5 ]]; do - sleep 0.05 2> /dev/null || true + while kill -0 "$INLINE_SPINNER_PID" 2>/dev/null && [[ $wait_count -lt 5 ]]; do + sleep 0.05 2>/dev/null || true ((wait_count++)) done # Only use SIGKILL as last resort if process is stuck - if kill -0 "$INLINE_SPINNER_PID" 2> /dev/null; then - kill -KILL "$INLINE_SPINNER_PID" 2> /dev/null || true + if kill -0 "$INLINE_SPINNER_PID" 2>/dev/null; then + kill -KILL "$INLINE_SPINNER_PID" 2>/dev/null || true fi - wait "$INLINE_SPINNER_PID" 2> /dev/null || true + wait "$INLINE_SPINNER_PID" 2>/dev/null || true # Cleanup - rm -f "$INLINE_SPINNER_STOP_FILE" 2> /dev/null || true + rm -f "$INLINE_SPINNER_STOP_FILE" 2>/dev/null || true INLINE_SPINNER_PID="" INLINE_SPINNER_STOP_FILE="" @@ -361,8 +361,8 @@ with_spinner() { 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 + "$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 } @@ -379,14 +379,14 @@ format_last_used_summary() { local value="$1" case "$value" in - "" | "Unknown") - echo "Unknown" - return 0 - ;; - "Never" | "Recent" | "Today" | "Yesterday" | "This year" | "Old") - echo "$value" - return 0 - ;; + "" | "Unknown") + echo "Unknown" + return 0 + ;; + "Never" | "Recent" | "Today" | "Yesterday" | "This year" | "Old") + echo "$value" + return 0 + ;; esac if [[ $value =~ ^([0-9]+)[[:space:]]+days?\ ago$ ]]; then @@ -444,7 +444,7 @@ has_full_disk_access() { if [[ -e "$test_path" ]]; then tested_count=$((tested_count + 1)) # Try to stat the ACTUAL protected path - this requires FDA - if stat "$test_path" > /dev/null 2>&1; then + if stat "$test_path" >/dev/null 2>&1; then accessible_count=$((accessible_count + 1)) fi fi From 46472bc86e250ca16f45ff106ad1364c6854b653 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 17 Jan 2026 02:13:54 +0000 Subject: [PATCH 11/91] chore: auto format code --- bin/clean.sh | 82 +++++++++--------- lib/clean/system.sh | 44 +++++----- lib/clean/user.sh | 60 ++++++------- lib/core/ui.sh | 200 ++++++++++++++++++++++---------------------- 4 files changed, 193 insertions(+), 193 deletions(-) diff --git a/bin/clean.sh b/bin/clean.sh index 3a33ed6..f97cf91 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -69,10 +69,10 @@ if [[ -f "$HOME/.config/mole/whitelist" ]]; then fi case "$line" in - / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /etc | /etc/* | /var/db | /var/db/*) - WHITELIST_WARNINGS+=("Protected system path: $line") - continue - ;; + / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /etc | /etc/* | /var/db | /var/db/*) + WHITELIST_WARNINGS+=("Protected system path: $line") + continue + ;; esac duplicate="false" @@ -86,7 +86,7 @@ if [[ -f "$HOME/.config/mole/whitelist" ]]; then fi [[ "$duplicate" == "true" ]] && continue WHITELIST_PATTERNS+=("$line") - done <"$HOME/.config/mole/whitelist" + done < "$HOME/.config/mole/whitelist" else WHITELIST_PATTERNS=("${DEFAULT_WHITELIST_PATTERNS[@]}") fi @@ -140,7 +140,7 @@ cleanup() { fi CLEANUP_DONE=true - stop_inline_spinner 2>/dev/null || true + stop_inline_spinner 2> /dev/null || true if [[ -t 1 ]]; then printf "\r\033[K" >&2 || true @@ -166,8 +166,8 @@ start_section() { if [[ "$DRY_RUN" == "true" ]]; then ensure_user_file "$EXPORT_LIST_FILE" - echo "" >>"$EXPORT_LIST_FILE" - echo "=== $1 ===" >>"$EXPORT_LIST_FILE" + echo "" >> "$EXPORT_LIST_FILE" + echo "=== $1 ===" >> "$EXPORT_LIST_FILE" fi } @@ -220,7 +220,7 @@ normalize_paths_for_cleanup() { done fi [[ "$is_child" == "true" ]] || result_paths+=("$path") - done <<<"$sorted_paths" + done <<< "$sorted_paths" if [[ ${#result_paths[@]} -gt 0 ]]; then printf '%s\n' "${result_paths[@]}" @@ -232,9 +232,9 @@ get_cleanup_path_size_kb() { local path="$1" if [[ -f "$path" && ! -L "$path" ]]; then - if command -v stat >/dev/null 2>&1; then + if command -v stat > /dev/null 2>&1; then local bytes - bytes=$(stat -f%z "$path" 2>/dev/null || echo "0") + bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then echo $(((bytes + 1023) / 1024)) return 0 @@ -243,9 +243,9 @@ get_cleanup_path_size_kb() { fi if [[ -L "$path" ]]; then - if command -v stat >/dev/null 2>&1; then + if command -v stat > /dev/null 2>&1; then local bytes - bytes=$(stat -f%z "$path" 2>/dev/null || echo "0") + bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then echo $(((bytes + 1023) / 1024)) else @@ -461,9 +461,9 @@ safe_clean() { [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 if [[ "$size" -gt 0 ]]; then - echo "$size 1" >"$temp_dir/result_${idx}" + echo "$size 1" > "$temp_dir/result_${idx}" else - echo "0 0" >"$temp_dir/result_${idx}" + echo "0 0" > "$temp_dir/result_${idx}" fi ((idx++)) @@ -488,17 +488,17 @@ safe_clean() { [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 local tmp_file="$temp_dir/result_${idx}.$$" if [[ "$size" -gt 0 ]]; then - echo "$size 1" >"$tmp_file" + echo "$size 1" > "$tmp_file" else - echo "0 0" >"$tmp_file" + echo "0 0" > "$tmp_file" fi - mv "$tmp_file" "$temp_dir/result_${idx}" 2>/dev/null || true + mv "$tmp_file" "$temp_dir/result_${idx}" 2> /dev/null || true ) & pids+=($!) ((idx++)) if ((${#pids[@]} >= MOLE_MAX_PARALLEL_JOBS)); then - wait "${pids[0]}" 2>/dev/null || true + wait "${pids[0]}" 2> /dev/null || true pids=("${pids[@]:1}") ((completed++)) @@ -511,7 +511,7 @@ safe_clean() { if [[ ${#pids[@]} -gt 0 ]]; then for pid in "${pids[@]}"; do - wait "$pid" 2>/dev/null || true + wait "$pid" 2> /dev/null || true ((completed++)) if [[ "$show_spinner" == "true" && -t 1 ]]; then @@ -527,11 +527,11 @@ safe_clean() { for path in "${existing_paths[@]}"; do local result_file="$temp_dir/result_${idx}" if [[ -f "$result_file" ]]; then - read -r size count <"$result_file" 2>/dev/null || true + read -r size count < "$result_file" 2> /dev/null || true local removed=0 if [[ "$DRY_RUN" != "true" ]]; then if [[ -L "$path" ]]; then - rm "$path" 2>/dev/null && removed=1 + rm "$path" 2> /dev/null && removed=1 else if safe_remove "$path" true; then removed=1 @@ -568,7 +568,7 @@ safe_clean() { local removed=0 if [[ "$DRY_RUN" != "true" ]]; then if [[ -L "$path" ]]; then - rm "$path" 2>/dev/null && removed=1 + rm "$path" 2> /dev/null && removed=1 else if safe_remove "$path" true; then removed=1 @@ -629,9 +629,9 @@ safe_clean() { local size=0 if [[ -n "${temp_dir:-}" && -f "$temp_dir/result_${idx}" ]]; then - read -r size count <"$temp_dir/result_${idx}" 2>/dev/null || true + read -r size count < "$temp_dir/result_${idx}" 2> /dev/null || true else - size=$(get_cleanup_path_size_kb "$path" 2>/dev/null || echo "0") + size=$(get_cleanup_path_size_kb "$path" 2> /dev/null || echo "0") fi [[ "$size" == "0" || -z "$size" ]] && { @@ -639,7 +639,7 @@ safe_clean() { continue } - echo "$(dirname "$path")|$size|$path" >>"$paths_temp" + echo "$(dirname "$path")|$size|$path" >> "$paths_temp" ((idx++)) done fi @@ -670,9 +670,9 @@ safe_clean() { ' | while IFS='|' read -r display_path total_size child_count; do local size_human=$(bytes_to_human "$((total_size * 1024))") if [[ $child_count -gt 1 ]]; then - echo "$display_path # $size_human ($child_count items)" >>"$EXPORT_LIST_FILE" + echo "$display_path # $size_human ($child_count items)" >> "$EXPORT_LIST_FILE" else - echo "$display_path # $size_human" >>"$EXPORT_LIST_FILE" + echo "$display_path # $size_human" >> "$EXPORT_LIST_FILE" fi done @@ -708,7 +708,7 @@ start_cleanup() { SYSTEM_CLEAN=false ensure_user_file "$EXPORT_LIST_FILE" - cat >"$EXPORT_LIST_FILE" < "$EXPORT_LIST_FILE" << EOF # Mole Cleanup Preview - $(date '+%Y-%m-%d %H:%M:%S') # # How to protect files: @@ -998,7 +998,7 @@ perform_cleanup() { echo "# Potential cleanup: ${freed_gb}GB" echo "# Items: $files_cleaned" echo "# Categories: $total_items" - } >>"$EXPORT_LIST_FILE" + } >> "$EXPORT_LIST_FILE" summary_details+=("Detailed file list: ${GRAY}$EXPORT_LIST_FILE${NC}") summary_details+=("Use ${GRAY}mo clean --whitelist${NC} to add protection rules") @@ -1047,17 +1047,17 @@ perform_cleanup() { main() { for arg in "$@"; do case "$arg" in - "--debug") - export MO_DEBUG=1 - ;; - "--dry-run" | "-n") - DRY_RUN=true - ;; - "--whitelist") - source "$SCRIPT_DIR/../lib/manage/whitelist.sh" - manage_whitelist "clean" - exit 0 - ;; + "--debug") + export MO_DEBUG=1 + ;; + "--dry-run" | "-n") + DRY_RUN=true + ;; + "--whitelist") + source "$SCRIPT_DIR/../lib/manage/whitelist.sh" + manage_whitelist "clean" + exit 0 + ;; esac done diff --git a/lib/clean/system.sh b/lib/clean/system.sh index aa1ce4a..d834319 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -27,14 +27,14 @@ clean_deep_system() { continue fi local item_flags - item_flags=$($STAT_BSD -f%Sf "$item" 2>/dev/null || echo "") + item_flags=$($STAT_BSD -f%Sf "$item" 2> /dev/null || echo "") if [[ "$item_flags" == *"restricted"* ]]; then continue fi if safe_sudo_remove "$item"; then ((updates_cleaned++)) fi - done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2>/dev/null || true) + done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) [[ $updates_cleaned -gt 0 ]] && log_success "System library updates" fi fi @@ -76,7 +76,7 @@ clean_deep_system() { last_update_time=$current_time fi fi - done < <(run_with_timeout 5 command find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2>/dev/null || true) + done < <(run_with_timeout 5 command find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true) stop_section_spinner [[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches ($code_sign_cleaned items)" @@ -101,7 +101,7 @@ clean_deep_system() { # Incomplete Time Machine backups. clean_time_machine_failed_backups() { local tm_cleaned=0 - if ! command -v tmutil >/dev/null 2>&1; then + if ! command -v tmutil > /dev/null 2>&1; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found" return 0 fi @@ -155,9 +155,9 @@ clean_time_machine_failed_backups() { fi for volume in "${backup_volumes[@]}"; do local fs_type - fs_type=$(run_with_timeout 1 command df -T "$volume" 2>/dev/null | tail -1 | awk '{print $2}' || echo "unknown") + fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "unknown") case "$fs_type" in - nfs | smbfs | afpfs | cifs | webdav | unknown) continue ;; + nfs | smbfs | afpfs | cifs | webdav | unknown) continue ;; esac local backupdb_dir="$volume/Backups.backupdb" if [[ -d "$backupdb_dir" ]]; then @@ -185,11 +185,11 @@ clean_time_machine_failed_backups() { note_activity continue fi - if ! command -v tmutil >/dev/null 2>&1; then + if ! command -v tmutil > /dev/null 2>&1; then echo -e " ${YELLOW}!${NC} tmutil not available, skipping: $backup_name" continue fi - if tmutil delete "$inprogress_file" 2>/dev/null; then + if tmutil delete "$inprogress_file" 2> /dev/null; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name ${GREEN}($size_human)${NC}" ((tm_cleaned++)) ((files_cleaned++)) @@ -199,14 +199,14 @@ clean_time_machine_failed_backups() { else echo -e " ${YELLOW}!${NC} Could not delete: $backup_name · try manually with sudo" fi - done < <(run_with_timeout 15 find "$backupdb_dir" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2>/dev/null || true) + done < <(run_with_timeout 15 find "$backupdb_dir" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) fi # APFS bundles. for bundle in "$volume"/*.backupbundle "$volume"/*.sparsebundle; do [[ -e "$bundle" ]] || continue [[ -d "$bundle" ]] || continue local bundle_name=$(basename "$bundle") - local mounted_path=$(hdiutil info 2>/dev/null | grep -A 5 "image-path.*$bundle_name" | grep "/Volumes/" | awk '{print $1}' | head -1 || echo "") + local mounted_path=$(hdiutil info 2> /dev/null | grep -A 5 "image-path.*$bundle_name" | grep "/Volumes/" | awk '{print $1}' | head -1 || echo "") if [[ -n "$mounted_path" && -d "$mounted_path" ]]; then while IFS= read -r inprogress_file; do [[ -d "$inprogress_file" ]] || continue @@ -231,10 +231,10 @@ clean_time_machine_failed_backups() { note_activity continue fi - if ! command -v tmutil >/dev/null 2>&1; then + if ! command -v tmutil > /dev/null 2>&1; then continue fi - if tmutil delete "$inprogress_file" 2>/dev/null; then + if tmutil delete "$inprogress_file" 2> /dev/null; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name ${GREEN}($size_human)${NC}" ((tm_cleaned++)) ((files_cleaned++)) @@ -244,7 +244,7 @@ clean_time_machine_failed_backups() { else echo -e " ${YELLOW}!${NC} Could not delete from bundle: $backup_name" fi - done < <(run_with_timeout 15 find "$mounted_path" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2>/dev/null || true) + done < <(run_with_timeout 15 find "$mounted_path" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) fi done done @@ -260,20 +260,20 @@ clean_time_machine_failed_backups() { # Returns 2 if status cannot be determined tm_is_running() { local st - st="$(tmutil status 2>/dev/null)" || return 2 + st="$(tmutil status 2> /dev/null)" || return 2 # If we can't find a Running field at all, treat as unknown. - if ! grep -qE '(^|[[:space:]])("Running"|Running)[[:space:]]*=' <<<"$st"; then + if ! grep -qE '(^|[[:space:]])("Running"|Running)[[:space:]]*=' <<< "$st"; then return 2 fi # Match: Running = 1; OR "Running" = 1 (with or without trailing ;) - grep -qE '(^|[[:space:]])("Running"|Running)[[:space:]]*=[[:space:]]*1([[:space:]]*;|$)' <<<"$st" + grep -qE '(^|[[:space:]])("Running"|Running)[[:space:]]*=[[:space:]]*1([[:space:]]*;|$)' <<< "$st" } # Local APFS snapshots (keep the most recent). clean_local_snapshots() { - if ! command -v tmutil >/dev/null 2>&1; then + if ! command -v tmutil > /dev/null 2>&1; then return 0 fi @@ -292,7 +292,7 @@ clean_local_snapshots() { start_section_spinner "Checking local snapshots..." local snapshot_list - snapshot_list=$(tmutil listlocalsnapshots / 2>/dev/null) + snapshot_list=$(tmutil listlocalsnapshots / 2> /dev/null) stop_section_spinner [[ -z "$snapshot_list" ]] && return 0 local cleaned_count=0 @@ -305,14 +305,14 @@ clean_local_snapshots() { local snap_name="${BASH_REMATCH[0]}" snapshots+=("$snap_name") local date_str="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]:0:2}:${BASH_REMATCH[4]:2:2}:${BASH_REMATCH[4]:4:2}" - local snap_ts=$(date -j -f "%Y-%m-%d %H:%M:%S" "$date_str" "+%s" 2>/dev/null || echo "0") + local snap_ts=$(date -j -f "%Y-%m-%d %H:%M:%S" "$date_str" "+%s" 2> /dev/null || echo "0") [[ "$snap_ts" == "0" ]] && continue if [[ "$snap_ts" -gt "$newest_ts" ]]; then newest_ts="$snap_ts" newest_name="$snap_name" fi fi - done <<<"$snapshot_list" + done <<< "$snapshot_list" [[ ${#snapshots[@]} -eq 0 ]] && return 0 [[ -z "$newest_name" ]] && return 0 @@ -331,7 +331,7 @@ clean_local_snapshots() { echo -e " ${GRAY}The most recent snapshot will be kept.${NC}" echo -ne " ${PURPLE}${ICON_ARROW}${NC} Remove all local snapshots except the most recent one? ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: " local choice - if type read_key >/dev/null 2>&1; then + if type read_key > /dev/null 2>&1; then choice=$(read_key) else IFS= read -r -s -n 1 choice || choice="" @@ -356,7 +356,7 @@ clean_local_snapshots() { ((cleaned_count++)) note_activity else - if sudo tmutil deletelocalsnapshots "${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]}-${BASH_REMATCH[4]}" >/dev/null 2>&1; then + if sudo tmutil deletelocalsnapshots "${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]}-${BASH_REMATCH[4]}" > /dev/null 2>&1; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed snapshot: $snap_name" ((cleaned_count++)) note_activity diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 7c82b5c..02baef9 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -20,7 +20,7 @@ clean_chrome_old_versions() { ) # Match the exact Chrome process name to avoid false positives - if pgrep -x "Google Chrome" >/dev/null 2>&1; then + if pgrep -x "Google Chrome" > /dev/null 2>&1; then echo -e " ${YELLOW}${ICON_WARNING}${NC} Google Chrome running · old versions cleanup skipped" return 0 fi @@ -39,7 +39,7 @@ clean_chrome_old_versions() { [[ -L "$current_link" ]] || continue local current_version - current_version=$(readlink "$current_link" 2>/dev/null || true) + current_version=$(readlink "$current_link" 2> /dev/null || true) current_version="${current_version##*/}" [[ -n "$current_version" ]] || continue @@ -69,9 +69,9 @@ clean_chrome_old_versions() { cleaned_any=true if [[ "$DRY_RUN" != "true" ]]; then if has_sudo_session; then - safe_sudo_remove "$dir" >/dev/null 2>&1 || true + safe_sudo_remove "$dir" > /dev/null 2>&1 || true else - safe_remove "$dir" true >/dev/null 2>&1 || true + safe_remove "$dir" true > /dev/null 2>&1 || true fi fi done @@ -100,7 +100,7 @@ clean_edge_old_versions() { ) # Match the exact Edge process name to avoid false positives (e.g., Microsoft Teams) - if pgrep -x "Microsoft Edge" >/dev/null 2>&1; then + if pgrep -x "Microsoft Edge" > /dev/null 2>&1; then echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · old versions cleanup skipped" return 0 fi @@ -119,7 +119,7 @@ clean_edge_old_versions() { [[ -L "$current_link" ]] || continue local current_version - current_version=$(readlink "$current_link" 2>/dev/null || true) + current_version=$(readlink "$current_link" 2> /dev/null || true) current_version="${current_version##*/}" [[ -n "$current_version" ]] || continue @@ -149,9 +149,9 @@ clean_edge_old_versions() { cleaned_any=true if [[ "$DRY_RUN" != "true" ]]; then if has_sudo_session; then - safe_sudo_remove "$dir" >/dev/null 2>&1 || true + safe_sudo_remove "$dir" > /dev/null 2>&1 || true else - safe_remove "$dir" true >/dev/null 2>&1 || true + safe_remove "$dir" true > /dev/null 2>&1 || true fi fi done @@ -177,7 +177,7 @@ clean_edge_updater_old_versions() { local updater_dir="$HOME/Library/Application Support/Microsoft/EdgeUpdater/apps/msedge-stable" [[ -d "$updater_dir" ]] || return 0 - if pgrep -x "Microsoft Edge" >/dev/null 2>&1; then + if pgrep -x "Microsoft Edge" > /dev/null 2>&1; then echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · updater cleanup skipped" return 0 fi @@ -215,7 +215,7 @@ clean_edge_updater_old_versions() { ((cleaned_count++)) cleaned_any=true if [[ "$DRY_RUN" != "true" ]]; then - safe_remove "$dir" true >/dev/null 2>&1 || true + safe_remove "$dir" true > /dev/null 2>&1 || true fi done @@ -242,20 +242,20 @@ scan_external_volumes() { [[ -d "$volume" && -w "$volume" && ! -L "$volume" ]] || continue [[ "$volume" == "/" || "$volume" == "/Volumes/Macintosh HD" ]] && continue local protocol="" - protocol=$(run_with_timeout 1 command diskutil info "$volume" 2>/dev/null | grep -i "Protocol:" | awk '{print $2}' || echo "") + protocol=$(run_with_timeout 1 command diskutil info "$volume" 2> /dev/null | grep -i "Protocol:" | awk '{print $2}' || echo "") case "$protocol" in - SMB | NFS | AFP | CIFS | WebDAV) - network_volumes+=("$volume") - continue - ;; + SMB | NFS | AFP | CIFS | WebDAV) + network_volumes+=("$volume") + continue + ;; esac local fs_type="" - fs_type=$(run_with_timeout 1 command df -T "$volume" 2>/dev/null | tail -1 | awk '{print $2}' || echo "") + fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "") case "$fs_type" in - nfs | smbfs | afpfs | cifs | webdav) - network_volumes+=("$volume") - continue - ;; + nfs | smbfs | afpfs | cifs | webdav) + network_volumes+=("$volume") + continue + ;; esac candidate_volumes+=("$volume") done @@ -275,7 +275,7 @@ scan_external_volumes() { if [[ -d "$volume_trash" && "$DRY_RUN" != "true" ]] && ! is_path_whitelisted "$volume_trash"; then while IFS= read -r -d '' item; do safe_remove "$item" true || true - done < <(command find "$volume_trash" -mindepth 1 -maxdepth 1 -print0 2>/dev/null || true) + done < <(command find "$volume_trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) fi if [[ "$PROTECT_FINDER_METADATA" != "true" ]]; then clean_ds_store_tree "$volume" "$(basename "$volume") volume (.DS_Store)" @@ -362,7 +362,7 @@ clean_mail_downloads() { ((cleaned_kb += file_size_kb)) fi fi - done < <(command find "$target_path" -type f -mtime +"$mail_age_days" -print0 2>/dev/null || true) + done < <(command find "$target_path" -type f -mtime +"$mail_age_days" -print0 2> /dev/null || true) fi done if [[ $count -gt 0 ]]; then @@ -420,7 +420,7 @@ process_container_cache() { local cache_dir="$container_dir/Data/Library/Caches" [[ -d "$cache_dir" ]] || return 0 # Fast non-empty check. - if find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then + if find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then local size=$(get_path_size_kb "$cache_dir") ((total_size += size)) found_any=true @@ -451,7 +451,7 @@ clean_browsers() { safe_clean ~/Library/Caches/company.thebrowser.dia/* "Dia cache" safe_clean ~/Library/Caches/BraveSoftware/Brave-Browser/* "Brave cache" local firefox_running=false - if pgrep -x "Firefox" >/dev/null 2>&1; then + if pgrep -x "Firefox" > /dev/null 2>&1; then firefox_running=true fi if [[ "$firefox_running" == "true" ]]; then @@ -504,7 +504,7 @@ clean_virtualization_tools() { } # Application Support logs/caches. clean_application_support_logs() { - if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" >/dev/null 2>&1; then + if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" > /dev/null 2>&1; then note_activity echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped: No permission to access Application Support" return 0 @@ -536,7 +536,7 @@ clean_application_support_logs() { local -a start_candidates=("$app_dir/log" "$app_dir/logs" "$app_dir/activitylog" "$app_dir/Cache/Cache_Data" "$app_dir/Crashpad/completed") for candidate in "${start_candidates[@]}"; do if [[ -d "$candidate" ]]; then - if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then + if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then local size=$(get_path_size_kb "$candidate") ((total_size += size)) ((cleaned_count++)) @@ -544,7 +544,7 @@ clean_application_support_logs() { if [[ "$DRY_RUN" != "true" ]]; then for item in "$candidate"/*; do [[ -e "$item" ]] || continue - safe_remove "$item" true >/dev/null 2>&1 || true + safe_remove "$item" true > /dev/null 2>&1 || true done fi fi @@ -560,7 +560,7 @@ clean_application_support_logs() { local -a gc_candidates=("$container_path/Logs" "$container_path/Library/Logs") for candidate in "${gc_candidates[@]}"; do if [[ -d "$candidate" ]]; then - if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then + if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then local size=$(get_path_size_kb "$candidate") ((total_size += size)) ((cleaned_count++)) @@ -568,7 +568,7 @@ clean_application_support_logs() { if [[ "$DRY_RUN" != "true" ]]; then for item in "$candidate"/*; do [[ -e "$item" ]] || continue - safe_remove "$item" true >/dev/null 2>&1 || true + safe_remove "$item" true > /dev/null 2>&1 || true done fi fi @@ -597,7 +597,7 @@ check_ios_device_backups() { if [[ -d "$backup_dir" ]]; then local backup_kb=$(get_path_size_kb "$backup_dir") if [[ -n "${backup_kb:-}" && "$backup_kb" -gt 102400 ]]; then - local backup_human=$(command du -sh "$backup_dir" 2>/dev/null | awk '{print $1}') + local backup_human=$(command du -sh "$backup_dir" 2> /dev/null | awk '{print $1}') if [[ -n "$backup_human" ]]; then note_activity echo -e " Found ${GREEN}${backup_human}${NC} iOS backups" diff --git a/lib/core/ui.sh b/lib/core/ui.sh index 257b385..cacb10b 100755 --- a/lib/core/ui.sh +++ b/lib/core/ui.sh @@ -168,47 +168,47 @@ read_key() { return 0 } case "$key" in - $'\n' | $'\r') echo "ENTER" ;; - $'\x7f' | $'\x08') echo "DELETE" ;; - $'\x1b') - # Check if this is an escape sequence (arrow keys) or ESC key - if IFS= read -r -s -n 1 -t 0.1 rest 2>/dev/null; then - if [[ "$rest" == "[" ]]; then - if IFS= read -r -s -n 1 -t 0.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 0.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 0.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 + $'\n' | $'\r') echo "ENTER" ;; + $'\x7f' | $'\x08') echo "DELETE" ;; + $'\x1b') + # Check if this is an escape sequence (arrow keys) or ESC key + if IFS= read -r -s -n 1 -t 0.1 rest 2> /dev/null; then + if [[ "$rest" == "[" ]]; then + if IFS= read -r -s -n 1 -t 0.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 0.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 0.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 + # Not an escape sequence, it's ESC key + echo "QUIT" + fi else - # Not an escape sequence, it's ESC key + # No following characters, it's ESC key echo "QUIT" fi - else - # No following characters, it's ESC key - echo "QUIT" - fi - ;; - ' ') echo "SPACE" ;; # Allow space in filter mode for selection - [[:print:]]) echo "CHAR:$key" ;; - *) echo "OTHER" ;; + ;; + ' ') echo "SPACE" ;; # Allow space in filter mode for selection + [[:print:]]) echo "CHAR:$key" ;; + *) echo "OTHER" ;; esac return 0 fi @@ -218,53 +218,53 @@ read_key() { return 0 } case "$key" in - $'\n' | $'\r') echo "ENTER" ;; - ' ') echo "SPACE" ;; - '/') echo "FILTER" ;; - 'q' | 'Q') echo "QUIT" ;; - 'R') echo "RETRY" ;; - 'm' | 'M') echo "MORE" ;; - 'u' | 'U') echo "UPDATE" ;; - 't' | 'T') echo "TOUCHID" ;; - 'j' | 'J') echo "DOWN" ;; - 'k' | 'K') echo "UP" ;; - 'h' | 'H') echo "LEFT" ;; - 'l' | 'L') echo "RIGHT" ;; - $'\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 + $'\n' | $'\r') echo "ENTER" ;; + ' ') echo "SPACE" ;; + '/') echo "FILTER" ;; + 'q' | 'Q') echo "QUIT" ;; + 'R') echo "RETRY" ;; + 'm' | 'M') echo "MORE" ;; + 'u' | 'U') echo "UPDATE" ;; + 't' | 'T') echo "TOUCHID" ;; + 'j' | 'J') echo "DOWN" ;; + 'k' | 'K') echo "UP" ;; + 'h' | 'H') echo "LEFT" ;; + 'l' | 'L') echo "RIGHT" ;; + $'\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 "OTHER"; fi - else echo "QUIT"; fi - ;; - [[:print:]]) echo "CHAR:$key" ;; - *) echo "OTHER" ;; + 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 + while IFS= read -r -s -n 1 -t 0.01 _ 2> /dev/null; do ((drained++)) [[ $drained -gt 100 ]] && break done @@ -288,7 +288,7 @@ INLINE_SPINNER_PID="" INLINE_SPINNER_STOP_FILE="" start_inline_spinner() { - stop_inline_spinner 2>/dev/null || true + stop_inline_spinner 2> /dev/null || true local message="$1" if [[ -t 1 ]]; then @@ -312,11 +312,11 @@ start_inline_spinner() { done # Clean up stop file before exiting - rm -f "$stop_file" 2>/dev/null || true + rm -f "$stop_file" 2> /dev/null || true exit 0 ) & INLINE_SPINNER_PID=$! - disown 2>/dev/null || true + disown 2> /dev/null || true else echo -n " ${BLUE}|${NC} $message" >&2 || true fi @@ -326,25 +326,25 @@ stop_inline_spinner() { if [[ -n "$INLINE_SPINNER_PID" ]]; then # Cooperative stop: create stop file to signal spinner to exit if [[ -n "$INLINE_SPINNER_STOP_FILE" ]]; then - touch "$INLINE_SPINNER_STOP_FILE" 2>/dev/null || true + touch "$INLINE_SPINNER_STOP_FILE" 2> /dev/null || true fi # Wait briefly for cooperative exit local wait_count=0 - while kill -0 "$INLINE_SPINNER_PID" 2>/dev/null && [[ $wait_count -lt 5 ]]; do - sleep 0.05 2>/dev/null || true + while kill -0 "$INLINE_SPINNER_PID" 2> /dev/null && [[ $wait_count -lt 5 ]]; do + sleep 0.05 2> /dev/null || true ((wait_count++)) done # Only use SIGKILL as last resort if process is stuck - if kill -0 "$INLINE_SPINNER_PID" 2>/dev/null; then - kill -KILL "$INLINE_SPINNER_PID" 2>/dev/null || true + if kill -0 "$INLINE_SPINNER_PID" 2> /dev/null; then + kill -KILL "$INLINE_SPINNER_PID" 2> /dev/null || true fi - wait "$INLINE_SPINNER_PID" 2>/dev/null || true + wait "$INLINE_SPINNER_PID" 2> /dev/null || true # Cleanup - rm -f "$INLINE_SPINNER_STOP_FILE" 2>/dev/null || true + rm -f "$INLINE_SPINNER_STOP_FILE" 2> /dev/null || true INLINE_SPINNER_PID="" INLINE_SPINNER_STOP_FILE="" @@ -361,8 +361,8 @@ with_spinner() { 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 + "$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 } @@ -379,14 +379,14 @@ format_last_used_summary() { local value="$1" case "$value" in - "" | "Unknown") - echo "Unknown" - return 0 - ;; - "Never" | "Recent" | "Today" | "Yesterday" | "This year" | "Old") - echo "$value" - return 0 - ;; + "" | "Unknown") + echo "Unknown" + return 0 + ;; + "Never" | "Recent" | "Today" | "Yesterday" | "This year" | "Old") + echo "$value" + return 0 + ;; esac if [[ $value =~ ^([0-9]+)[[:space:]]+days?\ ago$ ]]; then @@ -444,7 +444,7 @@ has_full_disk_access() { if [[ -e "$test_path" ]]; then tested_count=$((tested_count + 1)) # Try to stat the ACTUAL protected path - this requires FDA - if stat "$test_path" >/dev/null 2>&1; then + if stat "$test_path" > /dev/null 2>&1; then accessible_count=$((accessible_count + 1)) fi fi From f05441344a41b2e5caaaea70e65cc3c1103c74ce Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 17 Jan 2026 10:14:39 +0800 Subject: [PATCH 12/91] publish 1.22.0 --- mole | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mole b/mole index 353bce9..2d459b8 100755 --- a/mole +++ b/mole @@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh" trap cleanup_temp_files EXIT INT TERM # Version and update helpers -VERSION="1.21.0" +VERSION="1.22.0" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { From 36720007795be31241388e79a5d05f40bca8b53d Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 17 Jan 2026 10:20:58 +0800 Subject: [PATCH 13/91] update test --- tests/clean_user_core.bats | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/clean_user_core.bats b/tests/clean_user_core.bats index d2675d8..0ec5ddb 100644 --- a/tests/clean_user_core.bats +++ b/tests/clean_user_core.bats @@ -34,8 +34,8 @@ clean_user_essentials EOF [ "$status" -eq 0 ] - [[ "$output" == *"Trash"* ]] - [[ "$output" == *"whitelist"* ]] + # Whitelist-protected items no longer show output (UX improvement in V1.22.0) + [[ "$output" != *"Trash"* ]] } @test "clean_macos_system_caches calls safe_clean for core paths" { From d39d787c83529a91d5cc184ab70faef29505584d Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 17 Jan 2026 10:25:00 +0800 Subject: [PATCH 14/91] update test --- tests/clean_user_core.bats | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/clean_user_core.bats b/tests/clean_user_core.bats index 0ec5ddb..97b4cba 100644 --- a/tests/clean_user_core.bats +++ b/tests/clean_user_core.bats @@ -88,8 +88,8 @@ clean_finder_metadata EOF [ "$status" -eq 0 ] - [[ "$output" == *"Finder metadata"* ]] - [[ "$output" == *"protected"* ]] + # Whitelist-protected items no longer show output (UX improvement in V1.22.0) + [[ "$output" == "" ]] } @test "check_ios_device_backups returns when no backup dir" { From 72f42a363ea04fff958b5fc46a48c259727b8f4d Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 17 Jan 2026 10:46:11 +0800 Subject: [PATCH 15/91] chore: remove redundant sensors card and bump version to 1.22.1 - Disable sensors data collection (CPU temp already shown in CPU card) - Remove unused sensor-related functions (collectSensors, prettifyLabel, hasSensorData, renderSensorsCard) - Remove unused gopsutil/sensors import - Fix inline spinner disown call with explicit PID - Update version from 1.22.0 to 1.22.1 - Update SECURITY_AUDIT.md to match new version and date --- SECURITY_AUDIT.md | 6 +++--- cmd/status/metrics.go | 3 ++- cmd/status/metrics_battery.go | 28 ---------------------------- cmd/status/view.go | 30 ++++-------------------------- lib/core/ui.sh | 2 +- mole | 2 +- 6 files changed, 11 insertions(+), 60 deletions(-) diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 67c3971..ebec8fc 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -2,7 +2,7 @@
-**Status:** PASSED | **Risk Level:** LOW | **Version:** 1.21.0 (2026-01-15) +**Status:** PASSED | **Risk Level:** LOW | **Version:** 1.22.1 (2026-01-17)
@@ -12,9 +12,9 @@ | Attribute | Details | |-----------|---------| -| Audit Date | January 15, 2026 | +| Audit Date | January 17, 2026 | | Audit Conclusion | **PASSED** | -| Mole Version | V1.21.0 | +| Mole Version | V1.22.0 | | Audited Branch | `main` (HEAD) | | Scope | Shell scripts, Go binaries, Configuration | | Methodology | Static analysis, Threat modeling, Code review | diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go index 7617cd2..d573aa7 100644 --- a/cmd/status/metrics.go +++ b/cmd/status/metrics.go @@ -276,7 +276,8 @@ func (c *Collector) Collect() (MetricsSnapshot, error) { collect(func() (err error) { proxyStats = collectProxy(); return nil }) collect(func() (err error) { batteryStats, _ = collectBatteries(); return nil }) collect(func() (err error) { thermalStats = collectThermal(); return nil }) - collect(func() (err error) { sensorStats, _ = collectSensors(); return nil }) + // Sensors disabled - CPU temp already shown in CPU card + // collect(func() (err error) { sensorStats, _ = collectSensors(); return nil }) collect(func() (err error) { gpuStats, err = c.collectGPU(now); return }) collect(func() (err error) { // Bluetooth is slow; cache for 30s. diff --git a/cmd/status/metrics_battery.go b/cmd/status/metrics_battery.go index 4559188..c28e319 100644 --- a/cmd/status/metrics_battery.go +++ b/cmd/status/metrics_battery.go @@ -10,8 +10,6 @@ import ( "strconv" "strings" "time" - - "github.com/shirou/gopsutil/v4/sensors" ) var ( @@ -283,29 +281,3 @@ func collectThermal() ThermalStatus { return thermal } - -func collectSensors() ([]SensorReading, error) { - temps, err := sensors.SensorsTemperatures() - if err != nil { - return nil, err - } - var out []SensorReading - for _, t := range temps { - if t.Temperature <= 0 || t.Temperature > 150 { - continue - } - out = append(out, SensorReading{ - Label: prettifyLabel(t.SensorKey), - Value: t.Temperature, - Unit: "°C", - }) - } - return out, nil -} - -func prettifyLabel(key string) string { - key = strings.TrimSpace(key) - key = strings.TrimPrefix(key, "TC") - key = strings.ReplaceAll(key, "_", " ") - return key -} diff --git a/cmd/status/view.go b/cmd/status/view.go index a8a723a..2bb4f12 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -201,15 +201,6 @@ func getScoreStyle(score int) lipgloss.Style { } } -func hasSensorData(sensors []SensorReading) bool { - for _, s := range sensors { - if s.Note == "" && s.Value > 0 { - return true - } - } - return false -} - func renderCPUCard(cpu CPUStatus, thermal ThermalStatus) cardData { var lines []string @@ -411,9 +402,10 @@ func buildCards(m MetricsSnapshot, width int) []cardData { renderProcessCard(m.TopProcesses), renderNetworkCard(m.Network, m.NetworkHistory, m.Proxy, width), } - if hasSensorData(m.Sensors) { - cards = append(cards, renderSensorsCard(m.Sensors)) - } + // Sensors card disabled - redundant with CPU temp + // if hasSensorData(m.Sensors) { + // cards = append(cards, renderSensorsCard(m.Sensors)) + // } return cards } @@ -600,20 +592,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { return cardData{icon: iconBattery, title: "Power", lines: lines} } -func renderSensorsCard(sensors []SensorReading) cardData { - var lines []string - for _, s := range sensors { - if s.Note != "" { - continue - } - lines = append(lines, fmt.Sprintf("%-12s %s", shorten(s.Label, 12), colorizeTemp(s.Value)+s.Unit)) - } - if len(lines) == 0 { - lines = append(lines, subtleStyle.Render("No sensors")) - } - return cardData{icon: iconSensors, title: "Sensors", lines: lines} -} - func renderCard(data cardData, width int, height int) string { titleText := data.icon + " " + data.title lineLen := max(width-lipgloss.Width(titleText)-2, 4) diff --git a/lib/core/ui.sh b/lib/core/ui.sh index cacb10b..f9a8ea0 100755 --- a/lib/core/ui.sh +++ b/lib/core/ui.sh @@ -316,7 +316,7 @@ start_inline_spinner() { exit 0 ) & INLINE_SPINNER_PID=$! - disown 2> /dev/null || true + disown "$INLINE_SPINNER_PID" 2> /dev/null || true else echo -n " ${BLUE}|${NC} $message" >&2 || true fi diff --git a/mole b/mole index 2d459b8..b249154 100755 --- a/mole +++ b/mole @@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh" trap cleanup_temp_files EXIT INT TERM # Version and update helpers -VERSION="1.22.0" +VERSION="1.22.1" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { From 7a5bdac38537aecdced981f55ec8b6f347e3311f Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sun, 18 Jan 2026 16:52:44 +0800 Subject: [PATCH 16/91] feat: add uptime display to status header (#329) Show system uptime in the header row with a concise format (e.g., "up 1d 5h"). Simplify title from "Mole Status" to "Status" for better fit. --- cmd/status/metrics_health.go | 3 ++- cmd/status/metrics_health_test.go | 4 ++-- cmd/status/view.go | 5 ++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cmd/status/metrics_health.go b/cmd/status/metrics_health.go index 0e34828..8a8836a 100644 --- a/cmd/status/metrics_health.go +++ b/cmd/status/metrics_health.go @@ -159,7 +159,8 @@ func formatUptime(secs uint64) string { hours := (secs % 86400) / 3600 mins := (secs % 3600) / 60 if days > 0 { - return fmt.Sprintf("%dd %dh %dm", days, hours, mins) + // Only show days and hours when uptime is over 1 day (skip minutes for brevity) + return fmt.Sprintf("%dd %dh", days, hours) } if hours > 0 { return fmt.Sprintf("%dh %dm", hours, mins) diff --git a/cmd/status/metrics_health_test.go b/cmd/status/metrics_health_test.go index 3bd2497..e9c9205 100644 --- a/cmd/status/metrics_health_test.go +++ b/cmd/status/metrics_health_test.go @@ -52,8 +52,8 @@ func TestFormatUptime(t *testing.T) { if got := formatUptime(3600 + 120); got != "1h 2m" { t.Fatalf("expected \"1h 2m\", got %s", got) } - if got := formatUptime(86400*2 + 3600*3 + 60*5); got != "2d 3h 5m" { - t.Fatalf("expected \"2d 3h 5m\", got %s", got) + if got := formatUptime(86400*2 + 3600*3 + 60*5); got != "2d 3h" { + t.Fatalf("expected \"2d 3h\", got %s", got) } } diff --git a/cmd/status/view.go b/cmd/status/view.go index 2bb4f12..010e11a 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -131,7 +131,7 @@ type cardData struct { } func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int, catHidden bool) string { - title := titleStyle.Render("Mole Status") + title := titleStyle.Render("Status") scoreStyle := getScoreStyle(m.HealthScore) scoreText := subtleStyle.Render("Health ") + scoreStyle.Render(fmt.Sprintf("● %d", m.HealthScore)) @@ -165,6 +165,9 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int if m.Hardware.OSVersion != "" { infoParts = append(infoParts, m.Hardware.OSVersion) } + if m.Uptime != "" { + infoParts = append(infoParts, subtleStyle.Render("up "+m.Uptime)) + } headerLine := title + " " + scoreText + " " + strings.Join(infoParts, " · ") From 9b738488ec44cff748b5798db591f78dd564e4b8 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 19 Jan 2026 12:23:22 +0800 Subject: [PATCH 17/91] Delete AGENTS.md --- AGENTS.md | 412 ------------------------------------------------------ 1 file changed, 412 deletions(-) delete mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index a601056..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,412 +0,0 @@ -# AGENTS.md - Development Guide for Mole - -This guide provides AI coding assistants with essential commands, patterns, and conventions for working in the Mole codebase. - -**Quick reference**: Build/test commands • Safety rules • Architecture map • Code style - ---- - -## Safety Checklist - -Before any operation: - -- Use `safe_*` helpers (never raw `rm -rf` or `find -delete`) -- Check protection: `is_protected()`, `is_whitelisted()` -- Test first: `MO_DRY_RUN=1 ./mole clean` -- Validate syntax: `bash -n ` -- Run tests: `./scripts/test.sh` - -## NEVER Do These - -- Run `rm -rf` or any raw deletion commands -- Delete files without checking protection lists -- Modify system-critical paths (e.g., `/System`, `/Library/Apple`) -- Remove installer flags `--prefix`/`--config` from `install.sh` -- **Commit code changes or run `git commit` unless the user explicitly asks you to commit** -- **Reply to GitHub issues or PRs on behalf of the user** - only prepare responses for user review -- Run destructive operations without dry-run validation -- Use raw `git` commands when `gh` CLI is available - -## ALWAYS Do These - -- Use `safe_*` helper functions for deletions (`safe_rm`, `safe_find_delete`) -- Respect whitelist files (e.g., `~/.config/mole/whitelist`) -- Check protection logic before cleanup operations -- Test with dry-run modes first -- Validate syntax before suggesting changes: `bash -n ` -- **Prioritize `gh` CLI for ALL GitHub operations** - Always use `gh` to fetch and manipulate GitHub data (issues, PRs, releases, comments, etc.) instead of raw git commands or web scraping -- **Document fixes in AGENTS.md instead of committing or replying** - Prepare commit messages and GitHub responses for user review, don't execute them -- Review and update `SECURITY_AUDIT.md` when modifying `clean` or `optimize` logic - ---- - -## Quick Reference - -### Build Commands - -```bash -# Build Go binaries for current platform -make build - -# Build release binaries (cross-platform) -make release-amd64 # macOS Intel -make release-arm64 # macOS Apple Silicon - -# Clean build artifacts -make clean -``` - -### Test Commands - -```bash -# Run full test suite (recommended before commits) -./scripts/test.sh - -# Run specific BATS test file -bats tests/clean.bats - -# Run specific test case by name -bats tests/clean.bats -f "should respect whitelist" - -# Run Go tests only -go test -v ./cmd/... - -# Run Go tests for specific package -go test -v ./cmd/analyze - -# Shell syntax check -bash -n lib/clean/user.sh -bash -n mole - -# Lint shell scripts -shellcheck --rcfile .shellcheckrc lib/**/*.sh bin/**/*.sh -``` - -### Development Commands - -```bash -# Test cleanup in dry-run mode -MO_DRY_RUN=1 ./mole clean - -# Enable debug logging -MO_DEBUG=1 ./mole clean - -# Test Go tool directly -go run ./cmd/analyze - -# Test installation locally -./install.sh --prefix /usr/local/bin --config ~/.config/mole -``` - ---- - -## Architecture Quick Map - -``` -mole/ # Main CLI entrypoint (menu + routing) -├── mo # CLI alias wrapper -├── install.sh # Manual installer/updater (preserves --prefix/--config) -├── bin/ # Command entry points (thin wrappers) -│ ├── clean.sh # Deep cleanup orchestrator -│ ├── uninstall.sh # App removal with leftover detection -│ ├── optimize.sh # Cache rebuild + service refresh -│ ├── purge.sh # Aggressive cleanup mode -│ ├── touchid.sh # Touch ID sudo enabler -│ ├── analyze.sh # Disk usage explorer wrapper -│ ├── status.sh # System health dashboard wrapper -│ ├── installer.sh # Core installation logic -│ └── completion.sh # Shell completion support -├── lib/ # Reusable shell logic -│ ├── core/ # base.sh, log.sh, sudo.sh, ui.sh -│ ├── clean/ # Cleanup modules (user, apps, brew, system...) -│ ├── optimize/ # Optimization modules -│ ├── check/ # Health check modules -│ ├── manage/ # Management utilities -│ ├── ui/ # UI components (balloons, spinners) -│ └── uninstall/ # Uninstallation logic -├── cmd/ # Go applications -│ ├── analyze/ # Disk analysis tool -│ └── status/ # Real-time monitoring -├── scripts/ # Build and test automation -│ └── test.sh # Main test runner (shell + go + BATS) -└── tests/ # BATS integration tests -``` - -**Decision Tree**: - -- User cleanup logic → `lib/clean/.sh` -- Command entry → `bin/.sh` -- Core utils → `lib/core/.sh` -- Performance tool → `cmd//*.go` -- Tests → `tests/.bats` - -### Language Stack - -- **Shell (Bash 3.2)**: Core cleanup and system operations (`lib/`, `bin/`) -- **Go**: Performance-critical tools (`cmd/analyze/`, `cmd/status/`) -- **BATS**: Integration testing (`tests/`) - ---- - -## Code Style Guidelines - -### Shell Scripts - -- **Indentation**: 4 spaces (configured in .editorconfig) -- **Variables**: `lowercase_with_underscores` -- **Functions**: `verb_noun` format (e.g., `clean_caches`, `get_size`) -- **Constants**: `UPPERCASE_WITH_UNDERSCORES` -- **Quoting**: Always quote variables: `"$var"` not `$var` -- **Tests**: Use `[[` instead of `[` -- **Command substitution**: Use `$(command)` not backticks -- **Error handling**: Use `set -euo pipefail` at top of files - -### Go Code - -- **Formatting**: Follow standard Go conventions (`gofmt`, `go vet`) -- **Package docs**: Add package-level documentation for exported functions -- **Error handling**: Never ignore errors, always handle them explicitly -- **Build tags**: Use `//go:build darwin` for macOS-specific code - -### Comments - -- **Language**: English only -- **Focus**: Explain "why" not "what" (code should be self-documenting) -- **Safety**: Document safety boundaries explicitly -- **Non-obvious logic**: Explain workarounds or complex patterns - ---- - -## Key Helper Functions - -### Safety Helpers (lib/core/base.sh) - -- `safe_rm `: Safe deletion with validation -- `safe_find_delete `: Protected find+delete -- `is_protected `: Check if path is system-protected -- `is_whitelisted `: Check user whitelist - -### Logging (lib/core/log.sh) - -- `log_info `: Informational messages -- `log_success `: Success notifications -- `log_warn `: Warnings -- `log_error `: Error messages -- `debug `: Debug output (requires MO_DEBUG=1) - -### UI Helpers (lib/core/ui.sh) - -- `confirm `: Yes/no confirmation -- `show_progress `: Progress display - ---- - -## Testing Strategy - -### Test Types - -1. **Syntax Validation**: `bash -n ` - catches basic errors -2. **Unit Tests**: BATS tests for individual functions -3. **Integration Tests**: Full command execution with BATS -4. **Dry-run Tests**: `MO_DRY_RUN=1` to validate without deletion -5. **Go Tests**: `go test -v ./cmd/...` - -### Test Environment Variables - -- `MO_DRY_RUN=1`: Preview changes without execution -- `MO_DEBUG=1`: Enable detailed debug logging -- `BATS_FORMATTER=pretty`: Use pretty output for BATS (default) -- `BATS_FORMATTER=tap`: Use TAP output for CI - ---- - -## Common Development Tasks - -### Adding New Cleanup Module - -1. Create `lib/clean/new_module.sh` -2. Implement cleanup logic using `safe_*` helpers -3. Source it in `bin/clean.sh` -4. Add protection checks for critical paths -5. Write BATS test in `tests/clean.bats` -6. Test with `MO_DRY_RUN=1` first - -### Modifying Go Tools - -1. Navigate to `cmd//` -2. Make changes to Go files -3. Test with `go run .` or `make build && ./bin/-go` -4. Run `go test -v` for unit tests -5. Check integration: `./mole ` - -### Debugging Issues - -1. Enable debug mode: `MO_DEBUG=1 ./mole clean` -2. Check logs for error messages -3. Verify sudo permissions: `sudo -n true` or `./mole touchid` -4. Test individual functions in isolation -5. Use `shellcheck` for shell script issues - ---- - -## Linting and Quality - -### Shell Script Linting - -- **Tool**: shellcheck with custom `.shellcheckrc` -- **Disabled rules**: SC2155, SC2034, SC2059, SC1091, SC2038 -- **Command**: `shellcheck --rcfile .shellcheckrc lib/**/*.sh bin/**/*.sh` - -### Go Code Quality - -- **Tools**: `go vet`, `go fmt`, `go test` -- **Command**: `go vet ./cmd/... && go test ./cmd/...` - -### CI/CD Pipeline - -- **Triggers**: Push/PR to main, dev branches -- **Platforms**: macOS 14, macOS 15 -- **Tools**: bats-core, shellcheck, Go 1.24.6 -- **Security checks**: Unsafe rm usage, app protection, secret scanning - ---- - -## File Organization Patterns - -### Shell Modules - -- Entry scripts in `bin/` should be thin wrappers -- Reusable logic goes in `lib/` -- Core utilities in `lib/core/` -- Feature-specific modules in `lib/clean/`, `lib/ui/`, etc. - -### Go Packages - -- Each tool in its own `cmd//` directory -- Main entry point in `main.go` -- Use standard Go project layout -- macOS-specific code guarded with build tags - ---- - -## GitHub Operations - -### ⚡ ALWAYS Use gh CLI for GitHub Information - -**Golden Rule**: Whenever you need to fetch or manipulate GitHub data (issues, PRs, commits, releases, comments, etc.), **ALWAYS use `gh` CLI first**. It's more reliable, authenticated, and provides structured output compared to web scraping or raw git commands. - -**Preferred Commands**: - -```bash -# Issues -gh issue view 123 # View issue details -gh issue list # List issues -gh issue comment 123 "message" # Comment on issue - -# Pull Requests -gh pr view # View current PR -gh pr diff # Show diff -gh pr list # List PRs -gh pr checkout 123 # Checkout PR branch -gh pr merge # Merge current PR - -# Repository operations -gh release create v1.0.0 # Create release -gh repo view # Repository info -gh api repos/owner/repo/issues # Raw API access -``` - -**NEVER use raw git commands for GitHub operations** when `gh` is available: - -- ❌ `git log --oneline origin/main..HEAD` → ✅ `gh pr view` -- ❌ `git remote get-url origin` → ✅ `gh repo view` -- ❌ Manual GitHub API curl commands → ✅ `gh api` - -## Error Handling Patterns - -### Shell Scripts - -- Use `set -euo pipefail` for strict error handling -- Check command exit codes: `if command; then ...` -- Provide meaningful error messages with `log_error` -- Use cleanup traps for temporary resources - -### Go Code - -- Never ignore errors: `if err != nil { return err }` -- Use structured error messages -- Handle context cancellation appropriately -- Log errors with context information - ---- - -## Performance Considerations - -### Shell Optimization - -- Use built-in shell operations over external commands -- Prefer `find -delete` over `-exec rm` -- Minimize subprocess creation -- Use appropriate timeout mechanisms - -### Go Optimization - -- Use concurrency for I/O-bound operations -- Implement proper caching for expensive operations -- Profile memory usage in scanning operations -- Use efficient data structures for large datasets - ---- - -## Security Best Practices - -### Path Validation - -- Always validate user-provided paths -- Check against protection lists before operations -- Use absolute paths to prevent directory traversal -- Implement proper sandboxing for destructive operations - -### Permission Management - -- Request sudo only when necessary -- Use `sudo -n true` to check sudo availability -- Implement proper Touch ID integration -- Respect user whitelist configurations - ---- - -## Common Pitfalls to Avoid - -1. **Over-engineering**: Keep solutions simple. Don't add abstractions for one-time operations. -2. **Premature optimization**: Focus on correctness first, performance second. -3. **Assuming paths exist**: Always check before operating on files/directories. -4. **Ignoring protection logic**: User data loss is unacceptable. -5. **Breaking updates**: Keep `--prefix`/`--config` flags in `install.sh`. -6. **Platform assumptions**: Code must work on all supported macOS versions (10.13+). -7. **Silent failures**: Always log errors and provide actionable messages. - ---- - -## Communication Style - -- Be concise and technical -- Explain safety implications upfront -- Show before/after for significant changes -- Provide file:line references for code locations -- Suggest testing steps for validation - ---- - -## Resources - -- Main script: `mole` (menu + routing logic) -- Protection lists: Check `is_protected()` implementations -- User config: `~/.config/mole/` -- Test directory: `tests/` -- Build scripts: `scripts/` -- Documentation: `README.md`, `CONTRIBUTING.md`, `SECURITY_AUDIT.md` - ---- - -**Remember**: When in doubt, err on the side of safety. It's better to clean less than to risk user data. From 213dcf587d6b2d8dd9270f4e0946fca6f119520d Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 19 Jan 2026 16:12:53 +0800 Subject: [PATCH 18/91] Improve and ignore documents --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a00769c..a384879 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ CLAUDE.md GEMINI.md ANTIGRAVITY.md WARP.md +AGENTS.md .cursorrules # Go build artifacts (development) From d25c9f76376d83cbd15754b8932c0cb5ebd845f4 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 19 Jan 2026 16:53:51 +0800 Subject: [PATCH 19/91] refactor(uninstall): remove search and optimize scan performance - Remove complex search/filter feature from menu (simplify UX) - Optimize Homebrew cask detection (only check Caskroom symlinks) - Cache whoami outside loop, use bash parameter expansion - Fix spinner overlap with error messages - Add trap for Ctrl+C cleanup - Use ICON_ERROR for failure messages --- lib/ui/menu_paginated.sh | 413 ++++++++------------------------------- lib/uninstall/batch.sh | 55 +++--- 2 files changed, 107 insertions(+), 361 deletions(-) diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh index 26e8661..18c1503 100755 --- a/lib/ui/menu_paginated.sh +++ b/lib/ui/menu_paginated.sh @@ -87,13 +87,8 @@ paginated_multi_select() { local items_per_page=$(_pm_calculate_items_per_page) local cursor_pos=0 local top_index=0 - local filter_query="" - local filter_mode="false" # filter mode toggle local sort_mode="${MOLE_MENU_SORT_MODE:-${MOLE_MENU_SORT_DEFAULT:-date}}" # date|name|size local sort_reverse="${MOLE_MENU_SORT_REVERSE:-false}" - # Live query vs applied query - local applied_query="" - local searching="false" # Metadata (optional) # epochs[i] -> last_used_epoch (numeric) for item i @@ -124,36 +119,6 @@ paginated_multi_select() { view_indices[i]=$i done - # Escape for shell globbing without upsetting highlighters - _pm_escape_glob() { - local s="${1-}" out="" c - local i len=${#s} - for ((i = 0; i < len; i++)); do - c="${s:i:1}" - case "$c" in - $'\\' | '*' | '?' | '[' | ']') out+="\\$c" ;; - *) out+="$c" ;; - esac - done - printf '%s' "$out" - } - - # Case-insensitive fuzzy match (substring search) - _pm_match() { - local hay="$1" q="$2" - q="$(_pm_escape_glob "$q")" - local pat="*${q}*" - - shopt -s nocasematch - local ok=1 - # shellcheck disable=SC2254 # intentional glob match with a computed pattern - case "$hay" in - $pat) ok=0 ;; - esac - shopt -u nocasematch - return $ok - } - local -a selected=() local selected_count=0 # Cache selection count to avoid O(n) loops on every draw @@ -267,44 +232,13 @@ paginated_multi_select() { printf "%s%s\n" "$clear_line" "$line" >&2 } - # Rebuild the view_indices applying filter and sort + # Rebuild the view_indices applying sort rebuild_view() { - # Filter - local -a filtered=() - local effective_query="" - if [[ "$filter_mode" == "true" ]]; then - # Live editing: empty query -> show all items - effective_query="$filter_query" - if [[ -z "$effective_query" ]]; then - filtered=("${orig_indices[@]}") - else - local idx - for ((idx = 0; idx < total_items; idx++)); do - if _pm_match "${items[idx]}" "$effective_query"; then - filtered+=("$idx") - fi - done - fi - else - # Normal mode: use applied query; empty -> show all - effective_query="$applied_query" - if [[ -z "$effective_query" ]]; then - filtered=("${orig_indices[@]}") - else - local idx - for ((idx = 0; idx < total_items; idx++)); do - if _pm_match "${items[idx]}" "$effective_query"; then - filtered+=("$idx") - fi - done - fi - fi - # Sort (skip if no metadata) if [[ "$has_metadata" == "false" ]]; then - # No metadata: just use filtered list (already sorted by name naturally) - view_indices=("${filtered[@]}") - elif [[ ${#filtered[@]} -eq 0 ]]; then + # No metadata: just use original indices + view_indices=("${orig_indices[@]}") + elif [[ ${#orig_indices[@]} -eq 0 ]]; then view_indices=() else # Build sort key @@ -328,7 +262,7 @@ paginated_multi_select() { tmpfile=$(mktemp 2> /dev/null) || tmpfile="" if [[ -n "$tmpfile" ]]; then local k id - for id in "${filtered[@]}"; do + for id in "${orig_indices[@]}"; do case "$sort_mode" in date) k="${epochs[id]:-0}" ;; size) k="${sizekb[id]:-0}" ;; @@ -346,7 +280,7 @@ paginated_multi_select() { rm -f "$tmpfile" else # Fallback: no sorting - view_indices=("${filtered[@]}") + view_indices=("${orig_indices[@]}") fi fi @@ -404,34 +338,13 @@ paginated_multi_select() { # Visible slice local visible_total=${#view_indices[@]} if [[ $visible_total -eq 0 ]]; then - if [[ "$filter_mode" == "true" ]]; then - # While editing: do not show "No items available" - for ((i = 0; i < items_per_page; i++)); do - printf "${clear_line}\n" >&2 - done - printf "${clear_line}${GRAY}Type to filter | Delete | Enter Confirm | ESC Cancel${NC}\n" >&2 - printf "${clear_line}" >&2 - return - else - if [[ "$searching" == "true" ]]; then - printf "${clear_line}Searching…\n" >&2 - for ((i = 0; i < items_per_page; i++)); do - printf "${clear_line}\n" >&2 - done - printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter | / Filter | Q Exit${NC}\n" >&2 - printf "${clear_line}" >&2 - return - else - # Post-search: truly empty list - printf "${clear_line}No items available\n" >&2 - for ((i = 0; i < items_per_page; i++)); do - printf "${clear_line}\n" >&2 - done - printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter | / Filter | Q Exit${NC}\n" >&2 - printf "${clear_line}" >&2 - return - fi - fi + printf "${clear_line}No items available\n" >&2 + for ((i = 0; i < items_per_page; i++)); do + printf "${clear_line}\n" >&2 + done + printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter | Q Exit${NC}\n" >&2 + printf "${clear_line}" >&2 + return fi local visible_count=$((visible_total - top_index)) @@ -465,7 +378,7 @@ paginated_multi_select() { printf "${clear_line}\n" >&2 - # Build sort and filter status + # Build sort status local sort_label="" case "$sort_mode" in date) sort_label="Date" ;; @@ -474,15 +387,6 @@ paginated_multi_select() { esac local sort_status="${sort_label}" - local filter_status="" - if [[ "$filter_mode" == "true" ]]; then - filter_status="${filter_query:-_}" - elif [[ -n "$applied_query" ]]; then - filter_status="${applied_query}" - else - filter_status="—" - fi - # Footer: single line with controls local sep=" ${GRAY}|${NC} " @@ -497,77 +401,54 @@ paginated_multi_select() { # Common menu items local nav="${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}${NC}" local space_select="${GRAY}Space Select${NC}" - local space="${GRAY}Space${NC}" local enter="${GRAY}Enter${NC}" local exit="${GRAY}Q Exit${NC}" - if [[ "$filter_mode" == "true" ]]; then - # Filter mode: simple controls without sort - local -a _segs_filter=( - "${GRAY}Search: ${filter_status}${NC}" - "${GRAY}Delete${NC}" - "${GRAY}Enter Confirm${NC}" - "${GRAY}ESC Cancel${NC}" - ) - _print_wrapped_controls "$sep" "${_segs_filter[@]}" - else - # Normal mode - prepare dynamic items - local reverse_arrow="↑" - [[ "$sort_reverse" == "true" ]] && reverse_arrow="↓" + local reverse_arrow="↑" + [[ "$sort_reverse" == "true" ]] && reverse_arrow="↓" - local filter_text="/ Search" - [[ -n "$applied_query" ]] && filter_text="/ Clear" + local refresh="${GRAY}R Refresh${NC}" + local sort_ctrl="${GRAY}S ${sort_status}${NC}" + local order_ctrl="${GRAY}O ${reverse_arrow}${NC}" - local refresh="${GRAY}R Refresh${NC}" - local search="${GRAY}${filter_text}${NC}" - local sort_ctrl="${GRAY}S ${sort_status}${NC}" - local order_ctrl="${GRAY}O ${reverse_arrow}${NC}" + if [[ "$has_metadata" == "true" ]]; then + # With metadata: show sort controls + local term_width="${COLUMNS:-}" + [[ -z "$term_width" ]] && term_width=$(tput cols 2> /dev/null || echo 80) + [[ "$term_width" =~ ^[0-9]+$ ]] || term_width=80 - if [[ "$has_metadata" == "true" ]]; then - if [[ -n "$applied_query" ]]; then - # Filtering active: hide sort controls - local -a _segs_all=("$nav" "$space" "$enter" "$refresh" "$search" "$exit") - _print_wrapped_controls "$sep" "${_segs_all[@]}" - else - # Normal: show full controls with dynamic reduction - local term_width="${COLUMNS:-}" - [[ -z "$term_width" ]] && term_width=$(tput cols 2> /dev/null || echo 80) - [[ "$term_width" =~ ^[0-9]+$ ]] || term_width=80 + # Full controls + local -a _segs=("$nav" "$space_select" "$enter" "$refresh" "$sort_ctrl" "$order_ctrl" "$exit") - # Level 0: Full controls - local -a _segs=("$nav" "$space_select" "$enter" "$refresh" "$search" "$sort_ctrl" "$order_ctrl" "$exit") + # Calculate width + local total_len=0 seg_count=${#_segs[@]} + for i in "${!_segs[@]}"; do + total_len=$((total_len + $(_calc_len "${_segs[i]}"))) + [[ $i -lt $((seg_count - 1)) ]] && total_len=$((total_len + 3)) + done - # Calculate width - local total_len=0 seg_count=${#_segs[@]} - for i in "${!_segs[@]}"; do - total_len=$((total_len + $(_calc_len "${_segs[i]}"))) - [[ $i -lt $((seg_count - 1)) ]] && total_len=$((total_len + 3)) - done + # Level 1: Remove "Space Select" if too wide + if [[ $total_len -gt $term_width ]]; then + _segs=("$nav" "$enter" "$refresh" "$sort_ctrl" "$order_ctrl" "$exit") - # Level 1: Remove "Space Select" - if [[ $total_len -gt $term_width ]]; then - _segs=("$nav" "$enter" "$refresh" "$search" "$sort_ctrl" "$order_ctrl" "$exit") + total_len=0 + seg_count=${#_segs[@]} + for i in "${!_segs[@]}"; do + total_len=$((total_len + $(_calc_len "${_segs[i]}"))) + [[ $i -lt $((seg_count - 1)) ]] && total_len=$((total_len + 3)) + done - total_len=0 - seg_count=${#_segs[@]} - for i in "${!_segs[@]}"; do - total_len=$((total_len + $(_calc_len "${_segs[i]}"))) - [[ $i -lt $((seg_count - 1)) ]] && total_len=$((total_len + 3)) - done - - # Level 2: Remove "S ${sort_status}" - if [[ $total_len -gt $term_width ]]; then - _segs=("$nav" "$enter" "$refresh" "$search" "$order_ctrl" "$exit") - fi - fi - - _print_wrapped_controls "$sep" "${_segs[@]}" + # Level 2: Remove sort label if still too wide + if [[ $total_len -gt $term_width ]]; then + _segs=("$nav" "$enter" "$refresh" "$order_ctrl" "$exit") fi - else - # Without metadata: basic controls - local -a _segs_simple=("$nav" "$space_select" "$enter" "$refresh" "$search" "$exit") - _print_wrapped_controls "$sep" "${_segs_simple[@]}" fi + + _print_wrapped_controls "$sep" "${_segs[@]}" + else + # Without metadata: basic controls + local -a _segs_simple=("$nav" "$space_select" "$enter" "$refresh" "$exit") + _print_wrapped_controls "$sep" "${_segs_simple[@]}" fi printf "${clear_line}" >&2 } @@ -592,16 +473,6 @@ paginated_multi_select() { case "$key" in "QUIT") - if [[ "$filter_mode" == "true" ]]; then - filter_mode="false" - filter_query="" - applied_query="" - top_index=0 - cursor_pos=0 - rebuild_view - need_full_redraw=true - continue - fi cleanup return 1 ;; @@ -759,13 +630,7 @@ paginated_multi_select() { fi ;; "CHAR:s" | "CHAR:S") - if [[ "$filter_mode" == "true" ]]; then - local ch="${key#CHAR:}" - filter_query+="$ch" - rebuild_view - need_full_redraw=true - continue - elif [[ "$has_metadata" == "true" ]]; then + if [[ "$has_metadata" == "true" ]]; then # Cycle sort mode (only if metadata available) case "$sort_mode" in date) sort_mode="name" ;; @@ -776,135 +641,43 @@ paginated_multi_select() { need_full_redraw=true fi ;; - "FILTER") - # / key: toggle between filter and return - if [[ -n "$applied_query" ]]; then - # Already filtering, clear and return to full list - applied_query="" - filter_query="" - top_index=0 - cursor_pos=0 - rebuild_view - need_full_redraw=true - else - # Enter filter mode - filter_mode="true" - filter_query="" - top_index=0 - cursor_pos=0 - rebuild_view - need_full_redraw=true - fi - ;; "CHAR:j") - if [[ "$filter_mode" != "true" ]]; then - # Down navigation - if [[ ${#view_indices[@]} -gt 0 ]]; then - local absolute_index=$((top_index + cursor_pos)) - local last_index=$((${#view_indices[@]} - 1)) - if [[ $absolute_index -lt $last_index ]]; then - local visible_count=$((${#view_indices[@]} - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - ((cursor_pos++)) - elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((top_index++)) - fi + # Down navigation (vim style) + if [[ ${#view_indices[@]} -gt 0 ]]; then + local absolute_index=$((top_index + cursor_pos)) + local last_index=$((${#view_indices[@]} - 1)) + if [[ $absolute_index -lt $last_index ]]; then + local visible_count=$((${#view_indices[@]} - top_index)) + [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page + if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then + ((cursor_pos++)) + elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then + ((top_index++)) fi + need_full_redraw=true fi - else - filter_query+="j" - rebuild_view - need_full_redraw=true - continue fi ;; "CHAR:k") - if [[ "$filter_mode" != "true" ]]; then - # Up navigation - if [[ ${#view_indices[@]} -gt 0 ]]; then - if [[ $cursor_pos -gt 0 ]]; then - ((cursor_pos--)) - elif [[ $top_index -gt 0 ]]; then - ((top_index--)) - fi + # Up navigation (vim style) + if [[ ${#view_indices[@]} -gt 0 ]]; then + if [[ $cursor_pos -gt 0 ]]; then + ((cursor_pos--)) + need_full_redraw=true + elif [[ $top_index -gt 0 ]]; then + ((top_index--)) + need_full_redraw=true fi - else - filter_query+="k" - rebuild_view - need_full_redraw=true - continue fi ;; - "TOUCHID") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="t" - rebuild_view - need_full_redraw=true - continue - fi - ;; - "RIGHT") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="l" - rebuild_view - need_full_redraw=true - continue - fi - ;; - "LEFT") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="h" - rebuild_view - need_full_redraw=true - continue - fi - ;; - "MORE") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="m" - rebuild_view - need_full_redraw=true - continue - fi - ;; - "UPDATE") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="u" - rebuild_view - need_full_redraw=true - continue - fi - ;; - "CHAR:f" | "CHAR:F") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="${key#CHAR:}" - rebuild_view - need_full_redraw=true - continue - fi - # F is currently unbound in normal mode to avoid conflict with Refresh (R) - ;; "CHAR:r" | "CHAR:R") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="${key#CHAR:}" - rebuild_view - need_full_redraw=true - continue - else - # Trigger Refresh signal (Unified with Analyze) - cleanup - return 10 - fi + # Trigger Refresh signal + cleanup + return 10 ;; "CHAR:o" | "CHAR:O") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="${key#CHAR:}" - rebuild_view - need_full_redraw=true - continue - elif [[ "$has_metadata" == "true" ]]; then - # O toggles reverse order (Unified Sort Order) + if [[ "$has_metadata" == "true" ]]; then + # O toggles reverse order if [[ "$sort_reverse" == "true" ]]; then sort_reverse="false" else @@ -914,40 +687,8 @@ paginated_multi_select() { need_full_redraw=true fi ;; - "DELETE") - # Backspace filter - if [[ "$filter_mode" == "true" && -n "$filter_query" ]]; then - filter_query="${filter_query%?}" - # Rebuild view to apply filter in real-time - rebuild_view - # Trigger redraw and continue to avoid drain_pending_input - need_full_redraw=true - continue - fi - ;; - CHAR:*) - if [[ "$filter_mode" == "true" ]]; then - local ch="${key#CHAR:}" - # avoid accidental leading spaces - if [[ -n "$filter_query" || "$ch" != " " ]]; then - filter_query+="$ch" - # Rebuild view to apply filter in real-time - rebuild_view - # Trigger redraw and continue to avoid drain_pending_input - need_full_redraw=true - continue - fi - fi - ;; "ENTER") - if [[ "$filter_mode" == "true" ]]; then - applied_query="$filter_query" - filter_mode="false" - # Preserve cursor/top_index so navigation during search is respected - rebuild_view - # Fall through to confirmation logic - fi - # In normal mode: smart Enter behavior + # Smart Enter behavior # 1. Check if any items are already selected local has_selection=false for ((i = 0; i < total_items; i++)); do diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index b54fe08..ba5cf38 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -169,6 +169,9 @@ remove_file_list() { batch_uninstall_applications() { local total_size_freed=0 + # Trap to clean up spinner on interrupt + trap 'stop_inline_spinner 2>/dev/null; echo ""; return 130' INT TERM + # shellcheck disable=SC2154 if [[ ${#selected_apps[@]} -eq 0 ]]; then log_warning "No applications selected for uninstallation" @@ -181,33 +184,37 @@ batch_uninstall_applications() { local total_estimated_size=0 local -a app_details=() + # Cache current user outside loop + local current_user=$(whoami) + if [[ -t 1 ]]; then start_inline_spinner "Scanning files..."; fi for selected_app in "${selected_apps[@]}"; do [[ -z "$selected_app" ]] && continue IFS='|' read -r _ app_path app_name bundle_id _ _ <<< "$selected_app" - # Check running app by bundle executable if available. + # Check running app by bundle executable if available local exec_name="" - if [[ -e "$app_path/Contents/Info.plist" ]]; then - exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "") + local info_plist="$app_path/Contents/Info.plist" + if [[ -e "$info_plist" ]]; then + exec_name=$(defaults read "$info_plist" CFBundleExecutable 2>/dev/null || echo "") fi - local check_pattern="${exec_name:-$app_name}" - if pgrep -x "$check_pattern" > /dev/null 2>&1; then + if pgrep -qx "${exec_name:-$app_name}" 2>/dev/null; then running_apps+=("$app_name") fi - # Check if it's a Homebrew cask (deterministic: resolved path in Caskroom) - local cask_name="" - cask_name=$(get_brew_cask_name "$app_path" || echo "") - local is_brew_cask="false" - [[ -n "$cask_name" ]] && is_brew_cask="true" + # Check if it's a Homebrew cask (only if app is symlinked from Caskroom) + local cask_name="" is_brew_cask="false" + local resolved_path=$(readlink "$app_path" 2>/dev/null || echo "") + if [[ "$resolved_path" == */Caskroom/* ]]; then + # Extract cask name using bash parameter expansion (faster than sed) + local tmp="${resolved_path#*/Caskroom/}" + cask_name="${tmp%%/*}" + [[ -n "$cask_name" ]] && is_brew_cask="true" + fi - # Full file scanning for ALL apps (including Homebrew casks) - # brew uninstall --cask does NOT remove user data (caches, prefs, app support) - # Mole's value is cleaning those up, so we must scan for them + # Check if sudo is needed local needs_sudo=false local app_owner=$(get_file_owner "$app_path") - local current_user=$(whoami) if [[ ! -w "$(dirname "$app_path")" ]] || [[ "$app_owner" == "root" ]] || [[ -n "$app_owner" && "$app_owner" != "$current_user" ]]; then @@ -406,11 +413,12 @@ batch_uninstall_applications() { fi # Remove the application only if not running. + # Stop spinner before any removal attempt (avoids mixed output on errors) + [[ -t 1 ]] && stop_inline_spinner + local used_brew_successfully=false if [[ -z "$reason" ]]; then if [[ "$is_brew_cask" == "true" && -n "$cask_name" ]]; then - # Stop spinner before brew output - [[ -t 1 ]] && stop_inline_spinner # Use brew_uninstall_cask helper (handles env vars, timeout, verification) if brew_uninstall_cask "$cask_name" "$app_path"; then used_brew_successfully=true @@ -425,7 +433,6 @@ batch_uninstall_applications() { elif [[ "$needs_sudo" == true ]]; then if ! safe_sudo_remove "$app_path"; then local app_owner=$(get_file_owner "$app_path") - local current_user=$(whoami) if [[ -n "$app_owner" && "$app_owner" != "$current_user" && "$app_owner" != "root" ]]; then reason="owned by $app_owner" else @@ -460,13 +467,12 @@ batch_uninstall_applications() { fi fi - # Stop spinner and show success + # Show success if [[ -t 1 ]]; then - stop_inline_spinner if [[ ${#app_details[@]} -gt 1 ]]; then - echo -e "\r\033[K${GREEN}✓${NC} [$current_index/${#app_details[@]}] ${app_name}" + echo -e "${GREEN}✓${NC} [$current_index/${#app_details[@]}] ${app_name}" else - echo -e "\r\033[K${GREEN}✓${NC} ${app_name}" + echo -e "${GREEN}✓${NC} ${app_name}" fi fi @@ -477,13 +483,12 @@ batch_uninstall_applications() { ((total_items++)) success_items+=("$app_name") else - # Stop spinner and show failure + # Show failure if [[ -t 1 ]]; then - stop_inline_spinner if [[ ${#app_details[@]} -gt 1 ]]; then - echo -e "\r\033[K${RED}✗${NC} [$current_index/${#app_details[@]}] ${app_name} ${GRAY}($reason)${NC}" + echo -e "${ICON_ERROR} [$current_index/${#app_details[@]}] ${app_name} ${GRAY}($reason)${NC}" else - echo -e "\r\033[K${RED}✗${NC} ${app_name} failed: $reason" + echo -e "${ICON_ERROR} ${app_name} failed: $reason" fi fi From cec385efc74d5119aaa2266d65884d888bc7d49a Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 19 Jan 2026 08:54:45 +0000 Subject: [PATCH 20/91] chore: auto format code --- lib/uninstall/batch.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index ba5cf38..5e35b63 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -196,15 +196,15 @@ batch_uninstall_applications() { local exec_name="" local info_plist="$app_path/Contents/Info.plist" if [[ -e "$info_plist" ]]; then - exec_name=$(defaults read "$info_plist" CFBundleExecutable 2>/dev/null || echo "") + exec_name=$(defaults read "$info_plist" CFBundleExecutable 2> /dev/null || echo "") fi - if pgrep -qx "${exec_name:-$app_name}" 2>/dev/null; then + if pgrep -qx "${exec_name:-$app_name}" 2> /dev/null; then running_apps+=("$app_name") fi # Check if it's a Homebrew cask (only if app is symlinked from Caskroom) local cask_name="" is_brew_cask="false" - local resolved_path=$(readlink "$app_path" 2>/dev/null || echo "") + local resolved_path=$(readlink "$app_path" 2> /dev/null || echo "") if [[ "$resolved_path" == */Caskroom/* ]]; then # Extract cask name using bash parameter expansion (faster than sed) local tmp="${resolved_path#*/Caskroom/}" From 2d5ccf5d71bf43e2a17302e32b20f9a184beef52 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 19 Jan 2026 17:08:35 +0800 Subject: [PATCH 21/91] fix: exclude Flutter/CocoaPods/Pub caches from cleanup (#334) Prevent forced re-downloads and re-installations by not cleaning: - ~/Library/Caches/CocoaPods (spec repo cache) - ~/.cache/flutter (engine/artifacts cache) - ~/.pub-cache (Dart package cache) Also standardize redirection syntax to '> /dev/null' for consistency. Fixes #334 --- lib/clean/dev.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index 5ddfabc..6146f28 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -45,7 +45,7 @@ clean_dev_npm() { # Python/pip ecosystem caches. clean_dev_python() { if command -v pip3 > /dev/null 2>&1; then - clean_tool_cache "pip cache" bash -c 'pip3 cache purge >/dev/null 2>&1 || true' + clean_tool_cache "pip cache" bash -c 'pip3 cache purge > /dev/null 2>&1 || true' note_activity fi safe_clean ~/.pyenv/cache/* "pyenv cache" @@ -65,7 +65,7 @@ clean_dev_python() { # Go build/module caches. clean_dev_go() { if command -v go > /dev/null 2>&1; then - clean_tool_cache "Go cache" bash -c 'go clean -modcache >/dev/null 2>&1 || true; go clean -cache >/dev/null 2>&1 || true' + clean_tool_cache "Go cache" bash -c 'go clean -modcache > /dev/null 2>&1 || true; go clean -cache > /dev/null 2>&1 || true' note_activity fi } @@ -200,8 +200,8 @@ clean_dev_mobile() { # Simulator runtime caches. safe_clean ~/Library/Developer/CoreSimulator/Profiles/Runtimes/*/Contents/Resources/RuntimeRoot/System/Library/Caches/* "Simulator runtime cache" safe_clean ~/Library/Caches/Google/AndroidStudio*/* "Android Studio cache" - safe_clean ~/Library/Caches/CocoaPods/* "CocoaPods cache" - safe_clean ~/.cache/flutter/* "Flutter cache" + # safe_clean ~/Library/Caches/CocoaPods/* "CocoaPods cache" + # safe_clean ~/.cache/flutter/* "Flutter cache" safe_clean ~/.android/build-cache/* "Android build cache" safe_clean ~/.android/cache/* "Android SDK cache" safe_clean ~/Library/Developer/Xcode/UserData/IB\ Support/* "Xcode Interface Builder cache" @@ -219,7 +219,7 @@ clean_dev_other_langs() { safe_clean ~/.bundle/cache/* "Ruby Bundler cache" safe_clean ~/.composer/cache/* "PHP Composer cache" safe_clean ~/.nuget/packages/* "NuGet packages cache" - safe_clean ~/.pub-cache/* "Dart Pub cache" + # safe_clean ~/.pub-cache/* "Dart Pub cache" safe_clean ~/.cache/bazel/* "Bazel cache" safe_clean ~/.cache/zig/* "Zig cache" safe_clean ~/Library/Caches/deno/* "Deno cache" From ace682e0411553153382dc3f377f774a7ff1690f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 19:57:31 +0000 Subject: [PATCH 22/91] chore(deps): bump actions/cache from 5.0.1 to 5.0.2 Bumps [actions/cache](https://github.com/actions/cache) from 5.0.1 to 5.0.2. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/9255dc7a253b0ccc959486e2bca901246202afeb...8b402f58fbc84540c8b491a91e594a4576fec3d7) --- updated-dependencies: - dependency-name: actions/cache dependency-version: 5.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/check.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index aa38925..7a39c3b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -21,7 +21,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Cache Homebrew - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4 with: path: | ~/Library/Caches/Homebrew @@ -74,7 +74,7 @@ jobs: ref: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref) || github.ref }} - name: Cache Homebrew - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4 with: path: | ~/Library/Caches/Homebrew From 802adfc4c70cebdd0284f40c29232ff1d7931f3f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 19:57:37 +0000 Subject: [PATCH 23/91] chore(deps): bump actions/setup-go from 6.1.0 to 6.2.0 Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.1.0 to 6.2.0. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/4dc6199c7b1a012772edbd06daecab0f50c9053c...7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: 6.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/check.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index aa38925..4ff895b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -36,7 +36,7 @@ jobs: run: brew install shfmt shellcheck golangci-lint - name: Set up Go - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5 with: go-version: '1.24.6' @@ -89,7 +89,7 @@ jobs: run: brew install shfmt shellcheck golangci-lint - name: Set up Go - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5 with: go-version: '1.24.6' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6569766..71c974a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 - name: Set up Go - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5 with: go-version: "1.24.6" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dd53659..21a5efe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,7 @@ jobs: run: brew install bats-core shellcheck - name: Set up Go - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v5 with: go-version: "1.24.6" From 127e589291915c5afa8047d755880d6eabc8e550 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 20 Jan 2026 10:26:13 +0800 Subject: [PATCH 24/91] Fix test error case --- lib/uninstall/batch.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 5e35b63..b808367 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -202,7 +202,6 @@ batch_uninstall_applications() { running_apps+=("$app_name") fi - # Check if it's a Homebrew cask (only if app is symlinked from Caskroom) local cask_name="" is_brew_cask="false" local resolved_path=$(readlink "$app_path" 2> /dev/null || echo "") if [[ "$resolved_path" == */Caskroom/* ]]; then @@ -210,6 +209,13 @@ batch_uninstall_applications() { local tmp="${resolved_path#*/Caskroom/}" cask_name="${tmp%%/*}" [[ -n "$cask_name" ]] && is_brew_cask="true" + elif command -v get_brew_cask_name > /dev/null 2>&1; then + local detected_cask + detected_cask=$(get_brew_cask_name "$app_path" 2> /dev/null || true) + if [[ -n "$detected_cask" ]]; then + cask_name="$detected_cask" + is_brew_cask="true" + fi fi # Check if sudo is needed From d044b2876e2154ca8ab183481d3638575f34bbf3 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 20 Jan 2026 11:53:45 +0800 Subject: [PATCH 25/91] Fix unable to uninstall data-protected apps like Clash Party Previously, apps matching DATA_PROTECTED_BUNDLES patterns (VPNs, dev tools, etc.) could not be uninstalled because should_protect_path blocked their deletion. Now use MOLE_UNINSTALL_MODE to distinguish between cleanup and explicit uninstall, allowing users to remove these apps when they choose to while still protecting their data during normal cleanup operations. Also allow deletion of installer receipts in /private/var/db/receipts/. --- lib/core/app_protection.sh | 35 ++++++++++++++++++++++++++--------- lib/core/file_ops.sh | 3 ++- lib/uninstall/batch.sh | 11 +++++++++-- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index bcd1d97..1b754b8 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -493,6 +493,9 @@ should_protect_data() { # Check if a path is protected from deletion # Centralized logic to protect system settings, control center, and critical apps # +# In uninstall mode (MOLE_UNINSTALL_MODE=1), only system-critical components are protected. +# Data-protected apps (VPNs, dev tools, etc.) can be uninstalled when user explicitly chooses to. +# # Args: $1 - path to check # Returns: 0 if protected, 1 if safe to delete should_protect_path() { @@ -577,17 +580,31 @@ should_protect_path() { # 6. Match full path against protected patterns # This catches things like /Users/tw93/Library/Caches/Claude when pattern is *Claude* - for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do - if bundle_matches_pattern "$path" "$pattern"; then - return 0 - fi - done + # In uninstall mode, only check system-critical bundles (user explicitly chose to uninstall) + if [[ "${MOLE_UNINSTALL_MODE:-0}" == "1" ]]; then + # Uninstall mode: only protect system-critical components + for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do + if bundle_matches_pattern "$path" "$pattern"; then + return 0 + fi + done + else + # Normal mode (cleanup): protect both system-critical and data-protected bundles + for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do + if bundle_matches_pattern "$path" "$pattern"; then + return 0 + fi + done + fi # 7. Check if the filename itself matches any protected patterns - local filename - filename=$(basename "$path") - if should_protect_data "$filename"; then - return 0 + # Skip in uninstall mode - user explicitly chose to remove this app + if [[ "${MOLE_UNINSTALL_MODE:-0}" != "1" ]]; then + local filename + filename=$(basename "$path") + if should_protect_data "$filename"; then + return 0 + fi fi return 1 diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 9044dd6..24075ec 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -102,7 +102,8 @@ validate_path_for_deletion() { /private/var/db/diagnostics | /private/var/db/diagnostics/* | \ /private/var/db/DiagnosticPipeline | /private/var/db/DiagnosticPipeline/* | \ /private/var/db/powerlog | /private/var/db/powerlog/* | \ - /private/var/db/reportmemoryexception | /private/var/db/reportmemoryexception/*) + /private/var/db/reportmemoryexception | /private/var/db/reportmemoryexception/* | \ + /private/var/db/receipts/*.bom | /private/var/db/receipts/*.plist) return 0 ;; esac diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index b808367..cf8ba56 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -169,8 +169,8 @@ remove_file_list() { batch_uninstall_applications() { local total_size_freed=0 - # Trap to clean up spinner on interrupt - trap 'stop_inline_spinner 2>/dev/null; echo ""; return 130' INT TERM + # Trap to clean up spinner and uninstall mode on interrupt + trap 'stop_inline_spinner 2>/dev/null; unset MOLE_UNINSTALL_MODE; echo ""; return 130' INT TERM # shellcheck disable=SC2154 if [[ ${#selected_apps[@]} -eq 0 ]]; then @@ -360,6 +360,10 @@ batch_uninstall_applications() { ;; esac + # Enable uninstall mode - allows deletion of data-protected apps (VPNs, dev tools, etc.) + # that user explicitly chose to uninstall. System-critical components remain protected. + export MOLE_UNINSTALL_MODE=1 + # Request sudo if needed. if [[ ${#sudo_apps[@]} -gt 0 ]]; then if ! sudo -n true 2> /dev/null; then @@ -635,6 +639,9 @@ batch_uninstall_applications() { sudo_keepalive_pid="" fi + # Disable uninstall mode + unset MOLE_UNINSTALL_MODE + # Invalidate cache if any apps were successfully uninstalled. if [[ $success_count -gt 0 ]]; then local cache_file="$HOME/.cache/mole/app_scan_cache" From b4b9b0c474cbb1ef8d83b15ec9571896afd97a2a Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 20 Jan 2026 14:25:32 +0800 Subject: [PATCH 26/91] Add cleanup for orphaned system services Detect and remove leftover LaunchDaemons, LaunchAgents, and PrivilegedHelperTools from uninstalled apps like Sogou Input, ClashX, ClashMac, and Nektony App Cleaner. --- bin/clean.sh | 1 + lib/clean/apps.sh | 154 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/bin/clean.sh b/bin/clean.sh index f97cf91..e4b6521 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -952,6 +952,7 @@ perform_cleanup() { # ===== 12. Orphaned app data cleanup (60+ days inactive, skip protected vendors) ===== start_section "Uninstalled app data" clean_orphaned_app_data + clean_orphaned_system_services end_section # ===== 13. Apple Silicon optimizations ===== diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index 8130482..a214729 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -315,3 +315,157 @@ clean_orphaned_app_data() { fi rm -f "$installed_bundles" } + +# Clean orphaned system-level services (LaunchDaemons, LaunchAgents, PrivilegedHelperTools) +# These are left behind when apps are uninstalled but their system services remain +clean_orphaned_system_services() { + # Requires sudo + if ! sudo -n true 2>/dev/null; then + return 0 + fi + + start_section_spinner "Scanning orphaned system services..." + + # Build list of installed app bundle IDs + local installed_bundles + installed_bundles=$(create_temp_file) + scan_installed_apps "$installed_bundles" + + local orphaned_count=0 + local total_orphaned_kb=0 + local -a orphaned_files=() + + # Known bundle ID patterns for common apps that leave system services behind + # Format: "file_pattern:app_check_command" + local -a known_orphan_patterns=( + # Sogou Input Method + "com.sogou.*:/Library/Input Methods/SogouInput.app" + # ClashX + "com.west2online.ClashX.*:/Applications/ClashX.app" + # ClashMac + "com.clashmac.*:/Applications/ClashMac.app" + # Nektony App Cleaner + "com.nektony.AC*:/Applications/App Cleaner & Uninstaller.app" + # i4tools (爱思助手) + "cn.i4tools.*:/Applications/i4Tools.app" + ) + + # Scan system LaunchDaemons + if [[ -d /Library/LaunchDaemons ]]; then + while IFS= read -r -d '' plist; do + local filename + filename=$(basename "$plist") + + # Skip Apple system files + [[ "$filename" == com.apple.* ]] && continue + + # Extract bundle ID from filename (remove .plist extension) + local bundle_id="${filename%.plist}" + + # Check against known orphan patterns + for pattern_entry in "${known_orphan_patterns[@]}"; do + local file_pattern="${pattern_entry%%:*}" + local app_path="${pattern_entry#*:}" + + # shellcheck disable=SC2053 + if [[ "$bundle_id" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then + orphaned_files+=("$plist") + local size_kb + size_kb=$(sudo du -sk "$plist" 2>/dev/null | awk '{print $1}' || echo "0") + ((total_orphaned_kb += size_kb)) + ((orphaned_count++)) + break + fi + done + done < <(sudo find /Library/LaunchDaemons -maxdepth 1 -name "*.plist" -print0 2>/dev/null) + fi + + # Scan system LaunchAgents + if [[ -d /Library/LaunchAgents ]]; then + while IFS= read -r -d '' plist; do + local filename + filename=$(basename "$plist") + + # Skip Apple system files + [[ "$filename" == com.apple.* ]] && continue + + local bundle_id="${filename%.plist}" + + for pattern_entry in "${known_orphan_patterns[@]}"; do + local file_pattern="${pattern_entry%%:*}" + local app_path="${pattern_entry#*:}" + + # shellcheck disable=SC2053 + if [[ "$bundle_id" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then + orphaned_files+=("$plist") + local size_kb + size_kb=$(sudo du -sk "$plist" 2>/dev/null | awk '{print $1}' || echo "0") + ((total_orphaned_kb += size_kb)) + ((orphaned_count++)) + break + fi + done + done < <(sudo find /Library/LaunchAgents -maxdepth 1 -name "*.plist" -print0 2>/dev/null) + fi + + # Scan PrivilegedHelperTools + if [[ -d /Library/PrivilegedHelperTools ]]; then + while IFS= read -r -d '' helper; do + local filename + filename=$(basename "$helper") + + # Skip Apple system files + [[ "$filename" == com.apple.* ]] && continue + + for pattern_entry in "${known_orphan_patterns[@]}"; do + local file_pattern="${pattern_entry%%:*}" + local app_path="${pattern_entry#*:}" + + # shellcheck disable=SC2053 + if [[ "$filename" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then + orphaned_files+=("$helper") + local size_kb + size_kb=$(sudo du -sk "$helper" 2>/dev/null | awk '{print $1}' || echo "0") + ((total_orphaned_kb += size_kb)) + ((orphaned_count++)) + break + fi + done + done < <(sudo find /Library/PrivilegedHelperTools -maxdepth 1 -type f -print0 2>/dev/null) + fi + + stop_section_spinner + + # Report and clean + if [[ $orphaned_count -gt 0 ]]; then + echo -e " ${CYAN}${ICON_INFO}${NC} Found $orphaned_count orphaned system services" + + for orphan_file in "${orphaned_files[@]}"; do + local filename + filename=$(basename "$orphan_file") + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + debug_log "[DRY RUN] Would remove orphaned service: $orphan_file" + else + # Unload if it's a LaunchDaemon/LaunchAgent + if [[ "$orphan_file" == *.plist ]]; then + sudo launchctl unload "$orphan_file" 2>/dev/null || true + fi + if sudo rm -f "$orphan_file" 2>/dev/null; then + debug_log "Removed orphaned service: $orphan_file" + fi + fi + done + + local orphaned_kb_display + if [[ $total_orphaned_kb -gt 1024 ]]; then + orphaned_kb_display=$(echo "$total_orphaned_kb" | awk '{printf "%.1fMB", $1/1024}') + else + orphaned_kb_display="${total_orphaned_kb}KB" + fi + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count orphaned services (~$orphaned_kb_display)" + note_activity + fi + + rm -f "$installed_bundles" +} From acdf40dd2c4b912e9831029b899d3ffb5e92fb46 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 20 Jan 2026 14:44:11 +0800 Subject: [PATCH 27/91] Unify output format for multiple versions check --- lib/clean/dev.sh | 21 ++++++++++----------- tests/dev_extended.bats | 16 ++++++++-------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index 6146f28..034ffc5 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -77,12 +77,12 @@ clean_dev_rust() { } # Helper: Check for multiple versions in a directory. -# Args: $1=directory, $2=tool_name, $3+=additional_lines +# Args: $1=directory, $2=tool_name, $3=list_command, $4=remove_command check_multiple_versions() { local dir="$1" local tool_name="$2" - shift 2 - local -a additional_lines=("$@") + local list_cmd="${3:-}" + local remove_cmd="${4:-}" if [[ ! -d "$dir" ]]; then return 0 @@ -93,10 +93,11 @@ check_multiple_versions() { if [[ "$count" -gt 1 ]]; then note_activity - echo -e " Found ${GREEN}${count}${NC} ${tool_name}" - for line in "${additional_lines[@]}"; do - echo -e " $line" - done + local hint="" + if [[ -n "$list_cmd" ]]; then + hint=" · ${GRAY}${list_cmd}${NC}" + fi + echo -e " ${YELLOW}${ICON_WARNING}${NC} ${tool_name}: ${count} found${hint}" fi } @@ -107,8 +108,7 @@ check_rust_toolchains() { check_multiple_versions \ "$HOME/.rustup/toolchains" \ "Rust toolchains" \ - "You can list them with: ${GRAY}rustup toolchain list${NC}" \ - "Remove unused with: ${GRAY}rustup toolchain uninstall ${NC}" + "rustup toolchain list" } # Docker caches (guarded by daemon check). clean_dev_docker() { @@ -170,8 +170,7 @@ check_android_ndk() { check_multiple_versions \ "$HOME/Library/Android/sdk/ndk" \ "Android NDK versions" \ - "Manage in: ${GRAY}Android Studio → SDK Manager${NC}" \ - "Or manually at: ${GRAY}\$HOME/Library/Android/sdk/ndk${NC}" + "Android Studio → SDK Manager" } clean_dev_mobile() { diff --git a/tests/dev_extended.bats b/tests/dev_extended.bats index 4496ba1..8747fc0 100644 --- a/tests/dev_extended.bats +++ b/tests/dev_extended.bats @@ -123,29 +123,29 @@ EOF } @test "check_android_ndk reports multiple NDK versions" { - run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk"/{21.0.1,22.0.0,20.0.0} && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" + run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk"/{21.0.1,22.0.0,20.0.0} && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_WARNING="●" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" [ "$status" -eq 0 ] - [[ "$output" == *"Found 3 Android NDK versions"* ]] + [[ "$output" == *"Android NDK versions: 3 found"* ]] } @test "check_android_ndk silent when only one NDK" { - run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk/22.0.0" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" + run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk/22.0.0" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_WARNING="●" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" [ "$status" -eq 0 ] - [[ "$output" != *"Found"*"NDK"* ]] + [[ "$output" != *"NDK versions"* ]] } @test "check_rust_toolchains reports multiple toolchains" { - run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains"/{stable,nightly,1.75.0}-aarch64-apple-darwin && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" + run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains"/{stable,nightly,1.75.0}-aarch64-apple-darwin && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_WARNING="●" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" [ "$status" -eq 0 ] - [[ "$output" == *"Found 3 Rust toolchains"* ]] + [[ "$output" == *"Rust toolchains: 3 found"* ]] } @test "check_rust_toolchains silent when only one toolchain" { - run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains/stable-aarch64-apple-darwin" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" + run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains/stable-aarch64-apple-darwin" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_WARNING="●" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" [ "$status" -eq 0 ] - [[ "$output" != *"Found"*"Rust"* ]] + [[ "$output" != *"Rust toolchains"* ]] } From a96e53d9882c27f304f94660245154a1cb0285ac Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 20 Jan 2026 06:46:06 +0000 Subject: [PATCH 28/91] chore: auto format code --- lib/clean/apps.sh | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index a214729..2002802 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -320,7 +320,7 @@ clean_orphaned_app_data() { # These are left behind when apps are uninstalled but their system services remain clean_orphaned_system_services() { # Requires sudo - if ! sudo -n true 2>/dev/null; then + if ! sudo -n true 2> /dev/null; then return 0 fi @@ -371,13 +371,13 @@ clean_orphaned_system_services() { if [[ "$bundle_id" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then orphaned_files+=("$plist") local size_kb - size_kb=$(sudo du -sk "$plist" 2>/dev/null | awk '{print $1}' || echo "0") + size_kb=$(sudo du -sk "$plist" 2> /dev/null | awk '{print $1}' || echo "0") ((total_orphaned_kb += size_kb)) ((orphaned_count++)) break fi done - done < <(sudo find /Library/LaunchDaemons -maxdepth 1 -name "*.plist" -print0 2>/dev/null) + done < <(sudo find /Library/LaunchDaemons -maxdepth 1 -name "*.plist" -print0 2> /dev/null) fi # Scan system LaunchAgents @@ -399,13 +399,13 @@ clean_orphaned_system_services() { if [[ "$bundle_id" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then orphaned_files+=("$plist") local size_kb - size_kb=$(sudo du -sk "$plist" 2>/dev/null | awk '{print $1}' || echo "0") + size_kb=$(sudo du -sk "$plist" 2> /dev/null | awk '{print $1}' || echo "0") ((total_orphaned_kb += size_kb)) ((orphaned_count++)) break fi done - done < <(sudo find /Library/LaunchAgents -maxdepth 1 -name "*.plist" -print0 2>/dev/null) + done < <(sudo find /Library/LaunchAgents -maxdepth 1 -name "*.plist" -print0 2> /dev/null) fi # Scan PrivilegedHelperTools @@ -425,13 +425,13 @@ clean_orphaned_system_services() { if [[ "$filename" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then orphaned_files+=("$helper") local size_kb - size_kb=$(sudo du -sk "$helper" 2>/dev/null | awk '{print $1}' || echo "0") + size_kb=$(sudo du -sk "$helper" 2> /dev/null | awk '{print $1}' || echo "0") ((total_orphaned_kb += size_kb)) ((orphaned_count++)) break fi done - done < <(sudo find /Library/PrivilegedHelperTools -maxdepth 1 -type f -print0 2>/dev/null) + done < <(sudo find /Library/PrivilegedHelperTools -maxdepth 1 -type f -print0 2> /dev/null) fi stop_section_spinner @@ -449,9 +449,9 @@ clean_orphaned_system_services() { else # Unload if it's a LaunchDaemon/LaunchAgent if [[ "$orphan_file" == *.plist ]]; then - sudo launchctl unload "$orphan_file" 2>/dev/null || true + sudo launchctl unload "$orphan_file" 2> /dev/null || true fi - if sudo rm -f "$orphan_file" 2>/dev/null; then + if sudo rm -f "$orphan_file" 2> /dev/null; then debug_log "Removed orphaned service: $orphan_file" fi fi From 58522b4f90f359ec5d46b7d47e7151d977bb6b44 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 20 Jan 2026 15:03:08 +0800 Subject: [PATCH 29/91] Fix undefined ICON_INFO variable --- lib/clean/apps.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index a214729..cd06731 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -438,7 +438,7 @@ clean_orphaned_system_services() { # Report and clean if [[ $orphaned_count -gt 0 ]]; then - echo -e " ${CYAN}${ICON_INFO}${NC} Found $orphaned_count orphaned system services" + echo -e " ${YELLOW}${ICON_WARNING}${NC} Found $orphaned_count orphaned system services" for orphan_file in "${orphaned_files[@]}"; do local filename From 3f85c8238f690df8f9135ef5ae051f54c2a2baf6 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 20 Jan 2026 15:07:37 +0800 Subject: [PATCH 30/91] Use gray color for warning icons to reduce visual noise --- bin/clean.sh | 4 ++-- bin/optimize.sh | 10 +++++----- lib/check/all.sh | 28 ++++++++++++++-------------- lib/clean/app_caches.sh | 4 ++-- lib/clean/apps.sh | 4 ++-- lib/clean/brew.sh | 4 ++-- lib/clean/dev.sh | 2 +- lib/clean/system.sh | 2 +- lib/clean/user.sh | 12 ++++++------ lib/core/sudo.sh | 4 ++-- lib/manage/autofix.sh | 4 ++-- lib/uninstall/batch.sh | 2 +- 12 files changed, 40 insertions(+), 40 deletions(-) diff --git a/bin/clean.sh b/bin/clean.sh index e4b6521..74caadc 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -864,7 +864,7 @@ perform_cleanup() { fda_status=$? if [[ $fda_status -eq 1 ]]; then echo "" - echo -e "${YELLOW}${ICON_WARNING}${NC} ${GRAY}Tip: Grant Full Disk Access to your terminal in System Settings for best results${NC}" + echo -e "${GRAY}${ICON_WARNING}${NC} ${GRAY}Tip: Grant Full Disk Access to your terminal in System Settings for best results${NC}" fi fi @@ -889,7 +889,7 @@ perform_cleanup() { if [[ ${#WHITELIST_WARNINGS[@]} -gt 0 ]]; then echo "" for warning in "${WHITELIST_WARNINGS[@]}"; do - echo -e " ${YELLOW}${ICON_WARNING}${NC} Whitelist: $warning" + echo -e " ${GRAY}${ICON_WARNING}${NC} Whitelist: $warning" done fi diff --git a/bin/optimize.sh b/bin/optimize.sh index 07e7d81..0413b5a 100755 --- a/bin/optimize.sh +++ b/bin/optimize.sh @@ -204,7 +204,7 @@ cleanup_path() { return fi if should_protect_path "$expanded_path"; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Protected $label" + echo -e "${GRAY}${ICON_WARNING}${NC} Protected $label" return fi @@ -231,7 +231,7 @@ cleanup_path() { echo -e "${GREEN}${ICON_SUCCESS}${NC} $label" fi else - echo -e "${YELLOW}${ICON_WARNING}${NC} Skipped $label ${GRAY}(grant Full Disk Access to your terminal and retry)${NC}" + echo -e "${GRAY}${ICON_WARNING}${NC} Skipped $label ${GRAY}(grant Full Disk Access to your terminal and retry)${NC}" fi } @@ -304,7 +304,7 @@ apply_firewall_fix() { FIREWALL_DISABLED=false return 0 fi - echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to enable firewall (check permissions)" + echo -e " ${GRAY}${ICON_WARNING}${NC} Failed to enable firewall (check permissions)" return 1 } @@ -314,7 +314,7 @@ apply_gatekeeper_fix() { GATEKEEPER_DISABLED=false return 0 fi - echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to enable Gatekeeper" + echo -e " ${GRAY}${ICON_WARNING}${NC} Failed to enable Gatekeeper" return 1 } @@ -327,7 +327,7 @@ apply_touchid_fix() { perform_security_fixes() { if ! ensure_sudo_session "Security changes require admin access"; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Skipped security fixes (sudo denied)" + echo -e "${GRAY}${ICON_WARNING}${NC} Skipped security fixes (sudo denied)" return 1 fi diff --git a/lib/check/all.sh b/lib/check/all.sh index c00c5f5..f23dd70 100644 --- a/lib/check/all.sh +++ b/lib/check/all.sh @@ -48,7 +48,7 @@ check_touchid_sudo() { fi if [[ "$is_supported" == "true" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Touch ID ${YELLOW}Not configured for sudo${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} Touch ID ${YELLOW}Not configured for sudo${NC}" export TOUCHID_NOT_CONFIGURED=true fi fi @@ -62,7 +62,7 @@ check_rosetta() { if [[ -f "/Library/Apple/usr/share/rosetta/rosetta" ]]; then echo -e " ${GREEN}✓${NC} Rosetta 2 Intel app translation ready" else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Rosetta 2 ${YELLOW}Intel app support missing${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} Rosetta 2 ${YELLOW}Intel app support missing${NC}" export ROSETTA_NOT_INSTALLED=true fi fi @@ -79,7 +79,7 @@ check_git_config() { if [[ -n "$git_name" && -n "$git_email" ]]; then echo -e " ${GREEN}✓${NC} Git Global identity configured" else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Git ${YELLOW}User identity not set${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} Git ${YELLOW}User identity not set${NC}" fi fi } @@ -119,7 +119,7 @@ check_firewall() { if [[ "$firewall_output" == *"State = 1"* ]] || [[ "$firewall_output" == *"State = 2"* ]]; then echo -e " ${GREEN}✓${NC} Firewall Network protection enabled" else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Firewall ${YELLOW}Network protection disabled${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} Firewall ${YELLOW}Network protection disabled${NC}" export FIREWALL_DISABLED=true fi } @@ -134,7 +134,7 @@ check_gatekeeper() { echo -e " ${GREEN}✓${NC} Gatekeeper App download protection active" unset GATEKEEPER_DISABLED else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Gatekeeper ${YELLOW}App security disabled${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} Gatekeeper ${YELLOW}App security disabled${NC}" export GATEKEEPER_DISABLED=true fi fi @@ -149,7 +149,7 @@ check_sip() { if echo "$sip_status" | grep -q "enabled"; then echo -e " ${GREEN}✓${NC} SIP System integrity protected" else - echo -e " ${YELLOW}${ICON_WARNING}${NC} SIP ${YELLOW}System protection disabled${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} SIP ${YELLOW}System protection disabled${NC}" fi fi } @@ -276,7 +276,7 @@ check_macos_update() { export MACOS_UPDATE_AVAILABLE="$updates_available" if [[ "$updates_available" == "true" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS ${YELLOW}Update available${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} macOS ${YELLOW}Update available${NC}" else echo -e " ${GREEN}✓${NC} macOS System up to date" fi @@ -344,7 +344,7 @@ check_mole_update() { # Compare versions if [[ "$(printf '%s\n' "$current_version" "$latest_version" | sort -V | head -1)" == "$current_version" ]]; then export MOLE_UPDATE_AVAILABLE="true" - echo -e " ${YELLOW}${ICON_WARNING}${NC} Mole ${YELLOW}${latest_version} available${NC} (running ${current_version})" + echo -e " ${GRAY}${ICON_WARNING}${NC} Mole ${YELLOW}${latest_version} available${NC} (running ${current_version})" else echo -e " ${GREEN}✓${NC} Mole Latest version ${current_version}" fi @@ -408,7 +408,7 @@ check_disk_space() { if [[ $free_num -lt 20 ]]; then echo -e " ${RED}✗${NC} Disk Space ${RED}${free_gb}GB free${NC} (Critical)" elif [[ $free_num -lt 50 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Disk Space ${YELLOW}${free_gb}GB free${NC} (Low)" + echo -e " ${GRAY}${ICON_WARNING}${NC} Disk Space ${YELLOW}${free_gb}GB free${NC} (Low)" else echo -e " ${GREEN}✓${NC} Disk Space ${free_gb}GB free" fi @@ -454,7 +454,7 @@ check_memory_usage() { if [[ $used_percent -gt 90 ]]; then echo -e " ${RED}✗${NC} Memory ${RED}${used_percent}% used${NC} (Critical)" elif [[ $used_percent -gt 80 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Memory ${YELLOW}${used_percent}% used${NC} (High)" + echo -e " ${GRAY}${ICON_WARNING}${NC} Memory ${YELLOW}${used_percent}% used${NC} (High)" else echo -e " ${GREEN}✓${NC} Memory ${used_percent}% used" fi @@ -484,7 +484,7 @@ check_login_items() { fi if [[ $login_items_count -gt 15 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Login Items ${YELLOW}${login_items_count} apps${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} Login Items ${YELLOW}${login_items_count} apps${NC}" elif [[ $login_items_count -gt 0 ]]; then echo -e " ${GREEN}✓${NC} Login Items ${login_items_count} apps" else @@ -548,9 +548,9 @@ check_cache_size() { local cache_size_int=$(echo "$cache_size_gb" | cut -d'.' -f1) if [[ $cache_size_int -gt 10 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Cache Size ${YELLOW}${cache_size_gb}GB${NC} cleanable" + echo -e " ${GRAY}${ICON_WARNING}${NC} Cache Size ${YELLOW}${cache_size_gb}GB${NC} cleanable" elif [[ $cache_size_int -gt 5 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Cache Size ${YELLOW}${cache_size_gb}GB${NC} cleanable" + echo -e " ${GRAY}${ICON_WARNING}${NC} Cache Size ${YELLOW}${cache_size_gb}GB${NC} cleanable" else echo -e " ${GREEN}✓${NC} Cache Size ${cache_size_gb}GB" fi @@ -568,7 +568,7 @@ check_swap_usage() { if [[ "$swap_used" == *"G"* ]]; then local swap_gb=${swap_num%.*} if [[ $swap_gb -gt 2 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Swap Usage ${YELLOW}${swap_used}${NC} (High)" + echo -e " ${GRAY}${ICON_WARNING}${NC} Swap Usage ${YELLOW}${swap_used}${NC} (High)" else echo -e " ${GREEN}✓${NC} Swap Usage ${swap_used}" fi diff --git a/lib/clean/app_caches.sh b/lib/clean/app_caches.sh index c4b62c6..2402f09 100644 --- a/lib/clean/app_caches.sh +++ b/lib/clean/app_caches.sh @@ -18,7 +18,7 @@ clean_xcode_tools() { safe_clean ~/Library/Developer/Xcode/DerivedData/* "Xcode derived data" safe_clean ~/Library/Developer/Xcode/Archives/* "Xcode archives" else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode is running, skipping DerivedData and Archives cleanup" + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode is running, skipping DerivedData and Archives cleanup" fi } # Code editors. @@ -107,7 +107,7 @@ clean_media_players() { fi fi if [[ "$has_offline_music" == "true" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Spotify cache protected · offline music detected" + echo -e " ${GRAY}${ICON_WARNING}${NC} Spotify cache protected · offline music detected" note_activity else safe_clean ~/Library/Caches/com.spotify.client/* "Spotify cache" diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index cd06731..2c118e7 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -246,7 +246,7 @@ is_bundle_orphaned() { clean_orphaned_app_data() { if ! ls "$HOME/Library/Caches" > /dev/null 2>&1; then stop_section_spinner - echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped: No permission to access Library folders" + echo -e " ${GRAY}${ICON_WARNING}${NC} Skipped: No permission to access Library folders" return 0 fi start_section_spinner "Scanning installed apps..." @@ -438,7 +438,7 @@ clean_orphaned_system_services() { # Report and clean if [[ $orphaned_count -gt 0 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Found $orphaned_count orphaned system services" + echo -e " ${GRAY}${ICON_WARNING}${NC} Found $orphaned_count orphaned system services" for orphan_file in "${orphaned_files[@]}"; do local filename diff --git a/lib/clean/brew.sh b/lib/clean/brew.sh index b30fa7f..9939cb0 100644 --- a/lib/clean/brew.sh +++ b/lib/clean/brew.sh @@ -92,7 +92,7 @@ clean_homebrew() { fi fi elif [[ $brew_exit -eq 124 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Homebrew cleanup timed out · run ${GRAY}brew cleanup${NC} manually" + echo -e " ${GRAY}${ICON_WARNING}${NC} Homebrew cleanup timed out · run ${GRAY}brew cleanup${NC} manually" fi # Process autoremove output - only show if packages were removed # Only surface autoremove output when packages were removed. @@ -105,7 +105,7 @@ clean_homebrew() { echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed orphaned dependencies (${removed_packages} packages)" fi elif [[ $autoremove_exit -eq 124 ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Autoremove timed out · run ${GRAY}brew autoremove${NC} manually" + echo -e " ${GRAY}${ICON_WARNING}${NC} Autoremove timed out · run ${GRAY}brew autoremove${NC} manually" fi # Update cache timestamp on successful completion or when cleanup was intelligently skipped # This prevents repeated cache size checks within the 7-day window diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index 034ffc5..223a380 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -97,7 +97,7 @@ check_multiple_versions() { if [[ -n "$list_cmd" ]]; then hint=" · ${GRAY}${list_cmd}${NC}" fi - echo -e " ${YELLOW}${ICON_WARNING}${NC} ${tool_name}: ${count} found${hint}" + echo -e " ${GRAY}${ICON_WARNING}${NC} ${tool_name}: ${count} found${hint}" fi } diff --git a/lib/clean/system.sh b/lib/clean/system.sh index d834319..a186c67 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -323,7 +323,7 @@ clean_local_snapshots() { if [[ "$DRY_RUN" != "true" ]]; then if [[ ! -t 0 ]]; then echo -e " ${YELLOW}!${NC} ${#snapshots[@]} local snapshot(s) found, skipping non-interactive mode" - echo -e " ${YELLOW}${ICON_WARNING}${NC} ${GRAY}Tip: Snapshots may cause Disk Utility to show different 'Available' values${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} ${GRAY}Tip: Snapshots may cause Disk Utility to show different 'Available' values${NC}" return 0 fi echo -e " ${YELLOW}!${NC} Time Machine local snapshots found" diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 02baef9..c457f65 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -21,7 +21,7 @@ clean_chrome_old_versions() { # Match the exact Chrome process name to avoid false positives if pgrep -x "Google Chrome" > /dev/null 2>&1; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Google Chrome running · old versions cleanup skipped" + echo -e " ${GRAY}${ICON_WARNING}${NC} Google Chrome running · old versions cleanup skipped" return 0 fi @@ -101,7 +101,7 @@ clean_edge_old_versions() { # Match the exact Edge process name to avoid false positives (e.g., Microsoft Teams) if pgrep -x "Microsoft Edge" > /dev/null 2>&1; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · old versions cleanup skipped" + echo -e " ${GRAY}${ICON_WARNING}${NC} Microsoft Edge running · old versions cleanup skipped" return 0 fi @@ -178,7 +178,7 @@ clean_edge_updater_old_versions() { [[ -d "$updater_dir" ]] || return 0 if pgrep -x "Microsoft Edge" > /dev/null 2>&1; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · updater cleanup skipped" + echo -e " ${GRAY}${ICON_WARNING}${NC} Microsoft Edge running · updater cleanup skipped" return 0 fi @@ -455,7 +455,7 @@ clean_browsers() { firefox_running=true fi if [[ "$firefox_running" == "true" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Firefox is running · cache cleanup skipped" + echo -e " ${GRAY}${ICON_WARNING}${NC} Firefox is running · cache cleanup skipped" else safe_clean ~/Library/Caches/Firefox/* "Firefox cache" fi @@ -465,7 +465,7 @@ clean_browsers() { safe_clean ~/Library/Caches/com.kagi.kagimacOS/* "Orion cache" safe_clean ~/Library/Caches/zen/* "Zen cache" if [[ "$firefox_running" == "true" ]]; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Firefox is running · profile cache cleanup skipped" + echo -e " ${GRAY}${ICON_WARNING}${NC} Firefox is running · profile cache cleanup skipped" else safe_clean ~/Library/Application\ Support/Firefox/Profiles/*/cache2/* "Firefox profile cache" fi @@ -506,7 +506,7 @@ clean_virtualization_tools() { clean_application_support_logs() { if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" > /dev/null 2>&1; then note_activity - echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped: No permission to access Application Support" + echo -e " ${GRAY}${ICON_WARNING}${NC} Skipped: No permission to access Application Support" return 0 fi start_section_spinner "Scanning Application Support..." diff --git a/lib/core/sudo.sh b/lib/core/sudo.sh index 31011c6..5431db3 100644 --- a/lib/core/sudo.sh +++ b/lib/core/sudo.sh @@ -78,7 +78,7 @@ _request_password() { unset password ((attempts++)) if [[ $attempts -lt 3 ]]; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Password cannot be empty" > "$tty_path" + echo -e "${GRAY}${ICON_WARNING}${NC} Password cannot be empty" > "$tty_path" fi continue fi @@ -93,7 +93,7 @@ _request_password() { unset password ((attempts++)) if [[ $attempts -lt 3 ]]; then - echo -e "${YELLOW}${ICON_WARNING}${NC} Incorrect password, try again" > "$tty_path" + echo -e "${GRAY}${ICON_WARNING}${NC} Incorrect password, try again" > "$tty_path" fi done diff --git a/lib/manage/autofix.sh b/lib/manage/autofix.sh index d603f02..068add3 100644 --- a/lib/manage/autofix.sh +++ b/lib/manage/autofix.sh @@ -73,7 +73,7 @@ show_suggestions() { # Show auto-fix items if [[ ${#auto_fix_items[@]} -gt 0 ]]; then for item in "${auto_fix_items[@]}"; do - echo -e " ${YELLOW}${ICON_WARNING}${NC} ${item} ${GREEN}[auto]${NC}" + echo -e " ${GRAY}${ICON_WARNING}${NC} ${item} ${GREEN}[auto]${NC}" done fi @@ -82,7 +82,7 @@ show_suggestions() { for item in "${manual_items[@]}"; do local title="${item%%|*}" local hint="${item#*|}" - echo -e " ${YELLOW}${ICON_WARNING}${NC} ${title}" + echo -e " ${GRAY}${ICON_WARNING}${NC} ${title}" echo -e " ${GRAY}${hint}${NC}" done fi diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index cf8ba56..5084137 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -280,7 +280,7 @@ batch_uninstall_applications() { done if [[ "$has_user_data" == "true" ]]; then - echo -e "${YELLOW}${ICON_WARNING}${NC} ${YELLOW}Note: Some apps contain user configurations/themes${NC}" + echo -e "${GRAY}${ICON_WARNING}${NC} ${YELLOW}Note: Some apps contain user configurations/themes${NC}" echo "" fi From 31489715060b3f818ba384b46d0e881281f77889 Mon Sep 17 00:00:00 2001 From: Dylan Joss Date: Wed, 21 Jan 2026 00:15:35 -0800 Subject: [PATCH 31/91] test: add unit tests for RingBuffer in cmd/status Add comprehensive test coverage for the RingBuffer circular buffer data structure used in network history tracking. Test cases: - Constructor initialization (NewRingBuffer) - Empty buffer returns nil slice - Adding elements within capacity - Exact capacity boundary - Wrap-around behavior with chronological ordering - Multiple wrap-arounds stress test - Single-element buffer edge case - Slice returns independent copy (not reference) --- cmd/status/metrics_test.go | 160 +++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 cmd/status/metrics_test.go diff --git a/cmd/status/metrics_test.go b/cmd/status/metrics_test.go new file mode 100644 index 0000000..b269782 --- /dev/null +++ b/cmd/status/metrics_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestNewRingBuffer(t *testing.T) { + tests := []struct { + name string + capacity int + }{ + {"small buffer", 5}, + {"standard buffer", 120}, + {"single element", 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rb := NewRingBuffer(tt.capacity) + if rb == nil { + t.Fatal("NewRingBuffer returned nil") + } + if rb.cap != tt.capacity { + t.Errorf("expected capacity %d, got %d", tt.capacity, rb.cap) + } + if rb.size != 0 { + t.Errorf("expected size 0 for new buffer, got %d", rb.size) + } + if rb.index != 0 { + t.Errorf("expected index 0 for new buffer, got %d", rb.index) + } + if len(rb.data) != tt.capacity { + t.Errorf("expected data slice length %d, got %d", tt.capacity, len(rb.data)) + } + }) + } +} + +func TestRingBuffer_EmptyBuffer(t *testing.T) { + rb := NewRingBuffer(5) + result := rb.Slice() + + if result != nil { + t.Errorf("expected nil for empty buffer, got %v", result) + } +} + +func TestRingBuffer_AddWithinCapacity(t *testing.T) { + rb := NewRingBuffer(5) + + // Add 3 elements (less than capacity) + rb.Add(1.0) + rb.Add(2.0) + rb.Add(3.0) + + if rb.size != 3 { + t.Errorf("expected size 3, got %d", rb.size) + } + + result := rb.Slice() + expected := []float64{1.0, 2.0, 3.0} + + if !reflect.DeepEqual(result, expected) { + t.Errorf("expected %v, got %v", expected, result) + } +} + +func TestRingBuffer_ExactCapacity(t *testing.T) { + rb := NewRingBuffer(5) + + // Fill exactly to capacity + for i := 1; i <= 5; i++ { + rb.Add(float64(i)) + } + + if rb.size != 5 { + t.Errorf("expected size 5, got %d", rb.size) + } + + result := rb.Slice() + expected := []float64{1.0, 2.0, 3.0, 4.0, 5.0} + + if !reflect.DeepEqual(result, expected) { + t.Errorf("expected %v, got %v", expected, result) + } +} + +func TestRingBuffer_WrapAround(t *testing.T) { + rb := NewRingBuffer(5) + + // Add 7 elements to trigger wrap-around (2 past capacity) + // Internal state after: data=[6, 7, 3, 4, 5], index=2, size=5 + // Oldest element is at index 2 (value 3) + for i := 1; i <= 7; i++ { + rb.Add(float64(i)) + } + + if rb.size != 5 { + t.Errorf("expected size to cap at 5, got %d", rb.size) + } + + result := rb.Slice() + // Should return chronological order: 3, 4, 5, 6, 7 + expected := []float64{3.0, 4.0, 5.0, 6.0, 7.0} + + if !reflect.DeepEqual(result, expected) { + t.Errorf("expected %v, got %v", expected, result) + } +} + +func TestRingBuffer_MultipleWrapArounds(t *testing.T) { + rb := NewRingBuffer(3) + + // Add 10 elements (wraps multiple times) + for i := 1; i <= 10; i++ { + rb.Add(float64(i)) + } + + result := rb.Slice() + // Should have the last 3 values: 8, 9, 10 + expected := []float64{8.0, 9.0, 10.0} + + if !reflect.DeepEqual(result, expected) { + t.Errorf("expected %v, got %v", expected, result) + } +} + +func TestRingBuffer_SingleElementBuffer(t *testing.T) { + rb := NewRingBuffer(1) + + rb.Add(5.0) + result := rb.Slice() + if !reflect.DeepEqual(result, []float64{5.0}) { + t.Errorf("expected [5.0], got %v", result) + } + + // Overwrite the single element + rb.Add(10.0) + result = rb.Slice() + if !reflect.DeepEqual(result, []float64{10.0}) { + t.Errorf("expected [10.0], got %v", result) + } +} + +func TestRingBuffer_SliceReturnsNewSlice(t *testing.T) { + rb := NewRingBuffer(3) + rb.Add(1.0) + rb.Add(2.0) + + slice1 := rb.Slice() + slice2 := rb.Slice() + + // Modify slice1 and verify slice2 is unaffected + slice1[0] = 999.0 + + if slice2[0] == 999.0 { + t.Error("Slice should return a new copy, not a reference to internal data") + } +} From 046843a0125b44d278202cdb7da76781367ef4e2 Mon Sep 17 00:00:00 2001 From: Dylan Joss Date: Wed, 21 Jan 2026 11:29:37 -0800 Subject: [PATCH 32/91] Add more tests --- cmd/status/metrics_test.go | 89 +++++++++++++++++++++++--------------- 1 file changed, 55 insertions(+), 34 deletions(-) diff --git a/cmd/status/metrics_test.go b/cmd/status/metrics_test.go index b269782..f0f0a11 100644 --- a/cmd/status/metrics_test.go +++ b/cmd/status/metrics_test.go @@ -22,16 +22,16 @@ func TestNewRingBuffer(t *testing.T) { t.Fatal("NewRingBuffer returned nil") } if rb.cap != tt.capacity { - t.Errorf("expected capacity %d, got %d", tt.capacity, rb.cap) + t.Errorf("NewRingBuffer(%d).cap = %d, want %d", tt.capacity, rb.cap, tt.capacity) } if rb.size != 0 { - t.Errorf("expected size 0 for new buffer, got %d", rb.size) + t.Errorf("NewRingBuffer(%d).size = %d, want 0", tt.capacity, rb.size) } if rb.index != 0 { - t.Errorf("expected index 0 for new buffer, got %d", rb.index) + t.Errorf("NewRingBuffer(%d).index = %d, want 0", tt.capacity, rb.index) } if len(rb.data) != tt.capacity { - t.Errorf("expected data slice length %d, got %d", tt.capacity, len(rb.data)) + t.Errorf("len(NewRingBuffer(%d).data) = %d, want %d", tt.capacity, len(rb.data), tt.capacity) } }) } @@ -39,10 +39,10 @@ func TestNewRingBuffer(t *testing.T) { func TestRingBuffer_EmptyBuffer(t *testing.T) { rb := NewRingBuffer(5) - result := rb.Slice() + got := rb.Slice() - if result != nil { - t.Errorf("expected nil for empty buffer, got %v", result) + if got != nil { + t.Errorf("Slice() on empty buffer = %v, want nil", got) } } @@ -55,14 +55,14 @@ func TestRingBuffer_AddWithinCapacity(t *testing.T) { rb.Add(3.0) if rb.size != 3 { - t.Errorf("expected size 3, got %d", rb.size) + t.Errorf("size after 3 adds = %d, want 3", rb.size) } - result := rb.Slice() - expected := []float64{1.0, 2.0, 3.0} + got := rb.Slice() + want := []float64{1.0, 2.0, 3.0} - if !reflect.DeepEqual(result, expected) { - t.Errorf("expected %v, got %v", expected, result) + if !reflect.DeepEqual(got, want) { + t.Errorf("Slice() = %v, want %v", got, want) } } @@ -75,14 +75,14 @@ func TestRingBuffer_ExactCapacity(t *testing.T) { } if rb.size != 5 { - t.Errorf("expected size 5, got %d", rb.size) + t.Errorf("size after filling to capacity = %d, want 5", rb.size) } - result := rb.Slice() - expected := []float64{1.0, 2.0, 3.0, 4.0, 5.0} + got := rb.Slice() + want := []float64{1.0, 2.0, 3.0, 4.0, 5.0} - if !reflect.DeepEqual(result, expected) { - t.Errorf("expected %v, got %v", expected, result) + if !reflect.DeepEqual(got, want) { + t.Errorf("Slice() = %v, want %v", got, want) } } @@ -97,15 +97,20 @@ func TestRingBuffer_WrapAround(t *testing.T) { } if rb.size != 5 { - t.Errorf("expected size to cap at 5, got %d", rb.size) + t.Errorf("size after wrap-around = %d, want 5", rb.size) } - result := rb.Slice() - // Should return chronological order: 3, 4, 5, 6, 7 - expected := []float64{3.0, 4.0, 5.0, 6.0, 7.0} + // Verify index points to oldest element position + if rb.index != 2 { + t.Errorf("index after adding 7 elements to cap-5 buffer = %d, want 2", rb.index) + } - if !reflect.DeepEqual(result, expected) { - t.Errorf("expected %v, got %v", expected, result) + got := rb.Slice() + // Should return chronological order: oldest (3) to newest (7) + want := []float64{3.0, 4.0, 5.0, 6.0, 7.0} + + if !reflect.DeepEqual(got, want) { + t.Errorf("Slice() = %v, want %v", got, want) } } @@ -117,12 +122,12 @@ func TestRingBuffer_MultipleWrapArounds(t *testing.T) { rb.Add(float64(i)) } - result := rb.Slice() + got := rb.Slice() // Should have the last 3 values: 8, 9, 10 - expected := []float64{8.0, 9.0, 10.0} + want := []float64{8.0, 9.0, 10.0} - if !reflect.DeepEqual(result, expected) { - t.Errorf("expected %v, got %v", expected, result) + if !reflect.DeepEqual(got, want) { + t.Errorf("Slice() after 10 adds to cap-3 buffer = %v, want %v", got, want) } } @@ -130,16 +135,14 @@ func TestRingBuffer_SingleElementBuffer(t *testing.T) { rb := NewRingBuffer(1) rb.Add(5.0) - result := rb.Slice() - if !reflect.DeepEqual(result, []float64{5.0}) { - t.Errorf("expected [5.0], got %v", result) + if got := rb.Slice(); !reflect.DeepEqual(got, []float64{5.0}) { + t.Errorf("Slice() = %v, want [5.0]", got) } // Overwrite the single element rb.Add(10.0) - result = rb.Slice() - if !reflect.DeepEqual(result, []float64{10.0}) { - t.Errorf("expected [10.0], got %v", result) + if got := rb.Slice(); !reflect.DeepEqual(got, []float64{10.0}) { + t.Errorf("Slice() after overwrite = %v, want [10.0]", got) } } @@ -152,9 +155,27 @@ func TestRingBuffer_SliceReturnsNewSlice(t *testing.T) { slice2 := rb.Slice() // Modify slice1 and verify slice2 is unaffected + // This ensures Slice() returns a copy, not a reference to internal data slice1[0] = 999.0 if slice2[0] == 999.0 { - t.Error("Slice should return a new copy, not a reference to internal data") + t.Error("Slice() should return a new copy, not a reference to internal data") + } +} + +func TestRingBuffer_NegativeAndZeroValues(t *testing.T) { + rb := NewRingBuffer(4) + + // Test that negative and zero values are handled correctly + rb.Add(-5.0) + rb.Add(0.0) + rb.Add(-0.0) // Negative zero should work same as zero + rb.Add(3.5) + + got := rb.Slice() + want := []float64{-5.0, 0.0, 0.0, 3.5} + + if !reflect.DeepEqual(got, want) { + t.Errorf("Slice() with negative/zero values = %v, want %v", got, want) } } From 3c761865f8918594c32ca2dd1d64b7496e3eae30 Mon Sep 17 00:00:00 2001 From: Dylan Joss Date: Wed, 21 Jan 2026 11:46:50 -0800 Subject: [PATCH 33/91] Uses slices for equality comparison --- cmd/status/metrics_test.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/status/metrics_test.go b/cmd/status/metrics_test.go index f0f0a11..5515104 100644 --- a/cmd/status/metrics_test.go +++ b/cmd/status/metrics_test.go @@ -1,7 +1,7 @@ package main import ( - "reflect" + "slices" "testing" ) @@ -61,7 +61,7 @@ func TestRingBuffer_AddWithinCapacity(t *testing.T) { got := rb.Slice() want := []float64{1.0, 2.0, 3.0} - if !reflect.DeepEqual(got, want) { + if !slices.Equal(got, want) { t.Errorf("Slice() = %v, want %v", got, want) } } @@ -81,7 +81,7 @@ func TestRingBuffer_ExactCapacity(t *testing.T) { got := rb.Slice() want := []float64{1.0, 2.0, 3.0, 4.0, 5.0} - if !reflect.DeepEqual(got, want) { + if !slices.Equal(got, want) { t.Errorf("Slice() = %v, want %v", got, want) } } @@ -109,7 +109,7 @@ func TestRingBuffer_WrapAround(t *testing.T) { // Should return chronological order: oldest (3) to newest (7) want := []float64{3.0, 4.0, 5.0, 6.0, 7.0} - if !reflect.DeepEqual(got, want) { + if !slices.Equal(got, want) { t.Errorf("Slice() = %v, want %v", got, want) } } @@ -126,7 +126,7 @@ func TestRingBuffer_MultipleWrapArounds(t *testing.T) { // Should have the last 3 values: 8, 9, 10 want := []float64{8.0, 9.0, 10.0} - if !reflect.DeepEqual(got, want) { + if !slices.Equal(got, want) { t.Errorf("Slice() after 10 adds to cap-3 buffer = %v, want %v", got, want) } } @@ -135,13 +135,13 @@ func TestRingBuffer_SingleElementBuffer(t *testing.T) { rb := NewRingBuffer(1) rb.Add(5.0) - if got := rb.Slice(); !reflect.DeepEqual(got, []float64{5.0}) { + if got := rb.Slice(); !slices.Equal(got, []float64{5.0}) { t.Errorf("Slice() = %v, want [5.0]", got) } // Overwrite the single element rb.Add(10.0) - if got := rb.Slice(); !reflect.DeepEqual(got, []float64{10.0}) { + if got := rb.Slice(); !slices.Equal(got, []float64{10.0}) { t.Errorf("Slice() after overwrite = %v, want [10.0]", got) } } @@ -169,13 +169,13 @@ func TestRingBuffer_NegativeAndZeroValues(t *testing.T) { // Test that negative and zero values are handled correctly rb.Add(-5.0) rb.Add(0.0) - rb.Add(-0.0) // Negative zero should work same as zero + rb.Add(0.0) rb.Add(3.5) got := rb.Slice() want := []float64{-5.0, 0.0, 0.0, 3.5} - if !reflect.DeepEqual(got, want) { + if !slices.Equal(got, want) { t.Errorf("Slice() with negative/zero values = %v, want %v", got, want) } } From d675529052ecf8980f16c19f7f61a6a3e5f21150 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 03:10:48 +0000 Subject: [PATCH 34/91] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 73 ++++++++++++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 75befa1..5b531c5 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -57,6 +57,17 @@ + + + + + + + + dwjoss + + + @@ -67,7 +78,7 @@ rubnogueira - + @@ -78,7 +89,7 @@ biplavbarua - + @@ -89,7 +100,7 @@ bsisduck - + @@ -100,7 +111,7 @@ jimmystridh - + @@ -111,7 +122,7 @@ fte-jjmartres - + @@ -122,7 +133,7 @@ Else00 - + @@ -133,7 +144,7 @@ carolyn-sun - + @@ -144,7 +155,7 @@ MohammedEsafi - + @@ -155,7 +166,7 @@ ndbroadbent - + @@ -166,7 +177,7 @@ Sizk - + @@ -177,7 +188,7 @@ thijsvanhal - + @@ -188,7 +199,7 @@ yuzeguitarist - + @@ -199,7 +210,7 @@ zeldrisho - + @@ -210,7 +221,7 @@ bikraj2 - + @@ -221,7 +232,7 @@ bunizao - + @@ -232,7 +243,7 @@ frozturk - + @@ -243,7 +254,7 @@ huyixi - + @@ -254,7 +265,7 @@ purofle - + @@ -265,7 +276,7 @@ NanmiCoder - + @@ -276,7 +287,7 @@ Schlauer-Hax - + @@ -287,7 +298,7 @@ anonymort - + @@ -298,7 +309,7 @@ khipu-luke - + @@ -309,7 +320,7 @@ LmanTW - + @@ -320,7 +331,7 @@ kwakubiney - + @@ -331,7 +342,7 @@ kowyo - + @@ -342,7 +353,7 @@ jalen0x - + @@ -353,7 +364,7 @@ Hensell - + @@ -364,7 +375,7 @@ Copper-Eye - + @@ -375,7 +386,7 @@ ClathW - + @@ -386,7 +397,7 @@ andmev - + From 192ea08f8d353d82a80ef919789229a564581a88 Mon Sep 17 00:00:00 2001 From: Andrei Murariu <83287213+iamxorum@users.noreply.github.com> Date: Thu, 22 Jan 2026 05:16:42 +0200 Subject: [PATCH 35/91] bug-fix: mo analyze keybindings misalignment (#342) * bug-fix: mo analyze keybindings misalignment handle also uppercase R together with lowercase r * bug-fix: mo analyze keybindings misalignment Fix the logic also for: - q with Q - k with K - j with J - l with L - b with B - h with H - o with O --- cmd/analyze/main.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index 7749d24..0978187 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -584,7 +584,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } switch msg.String() { - case "q", "ctrl+c": + case "q", "ctrl+c", "Q": return m, tea.Quit case "esc": if m.showLargeFiles { @@ -592,7 +592,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } return m, tea.Quit - case "up", "k": + case "up", "k", "K": if m.showLargeFiles { if m.largeSelected > 0 { m.largeSelected-- @@ -606,7 +606,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.offset = m.selected } } - case "down", "j": + case "down", "j", "J": if m.showLargeFiles { if m.largeSelected < len(m.largeFiles)-1 { m.largeSelected++ @@ -622,12 +622,12 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.offset = m.selected - viewport + 1 } } - case "enter", "right", "l": + case "enter", "right", "l", "L": if m.showLargeFiles { return m, nil } return m.enterSelectedDir() - case "b", "left", "h": + case "b", "left", "h", "B", "H": if m.showLargeFiles { m.showLargeFiles = false return m, nil @@ -679,7 +679,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) m.scanning = false return m, nil - case "r": + case "r", "R": m.multiSelected = make(map[string]bool) m.largeMultiSelected = make(map[string]bool) @@ -728,7 +728,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) } - case "o": + case "o", "O": // Open selected entries (multi-select aware). const maxBatchOpen = 20 if m.showLargeFiles { From cd367b67b9a370cfd98aeea9c7ab415e048ae4be Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 03:17:01 +0000 Subject: [PATCH 36/91] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 73 ++++++++++++++++++++---------------------------- 1 file changed, 31 insertions(+), 42 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 5b531c5..75befa1 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -57,17 +57,6 @@ - - - - - - - - dwjoss - - - @@ -78,7 +67,7 @@ rubnogueira - + @@ -89,7 +78,7 @@ biplavbarua - + @@ -100,7 +89,7 @@ bsisduck - + @@ -111,7 +100,7 @@ jimmystridh - + @@ -122,7 +111,7 @@ fte-jjmartres - + @@ -133,7 +122,7 @@ Else00 - + @@ -144,7 +133,7 @@ carolyn-sun - + @@ -155,7 +144,7 @@ MohammedEsafi - + @@ -166,7 +155,7 @@ ndbroadbent - + @@ -177,7 +166,7 @@ Sizk - + @@ -188,7 +177,7 @@ thijsvanhal - + @@ -199,7 +188,7 @@ yuzeguitarist - + @@ -210,7 +199,7 @@ zeldrisho - + @@ -221,7 +210,7 @@ bikraj2 - + @@ -232,7 +221,7 @@ bunizao - + @@ -243,7 +232,7 @@ frozturk - + @@ -254,7 +243,7 @@ huyixi - + @@ -265,7 +254,7 @@ purofle - + @@ -276,7 +265,7 @@ NanmiCoder - + @@ -287,7 +276,7 @@ Schlauer-Hax - + @@ -298,7 +287,7 @@ anonymort - + @@ -309,7 +298,7 @@ khipu-luke - + @@ -320,7 +309,7 @@ LmanTW - + @@ -331,7 +320,7 @@ kwakubiney - + @@ -342,7 +331,7 @@ kowyo - + @@ -353,7 +342,7 @@ jalen0x - + @@ -364,7 +353,7 @@ Hensell - + @@ -375,7 +364,7 @@ Copper-Eye - + @@ -386,7 +375,7 @@ ClathW - + @@ -397,7 +386,7 @@ andmev - + From 217eb986e2c9450f7767b294a813e4b31bd3488d Mon Sep 17 00:00:00 2001 From: Dylan Joss Date: Wed, 21 Jan 2026 22:42:13 -0800 Subject: [PATCH 37/91] test: add formatUnusedTime tests and fix duplicate comment Add unit tests for formatUnusedTime in cmd/analyze/format_test.go: - Zero time (time.Time{}) returns empty string - Recent files (< 90 days) return empty string - Boundary tests at 89/90 days threshold - Month formatting (3mo, 4mo, 6mo, 11mo, 12mo) - Year formatting (1yr, 2yr, 3yr) - Boundary tests at year transitions Also remove duplicate "Memory pressure penalty" comment in cmd/status/metrics_health.go (line 72-73). --- cmd/analyze/format_test.go | 39 ++++++++++++++++++++++++++++++++++++ cmd/status/metrics_health.go | 1 - 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/cmd/analyze/format_test.go b/cmd/analyze/format_test.go index f328212..de7022f 100644 --- a/cmd/analyze/format_test.go +++ b/cmd/analyze/format_test.go @@ -3,6 +3,7 @@ package main import ( "strings" "testing" + "time" ) func TestRuneWidth(t *testing.T) { @@ -307,3 +308,41 @@ func TestCalculateNameWidth(t *testing.T) { } } } + +func TestFormatUnusedTime(t *testing.T) { + tests := []struct { + name string + daysAgo int + want string + }{ + {"zero time", -1, ""}, // Special case: will use time.Time{} + {"recent file", 30, ""}, // < 90 days returns empty + {"just under threshold", 89, ""}, // Boundary: 89 days still empty + {"at 90 days", 90, ">3mo"}, // Boundary: exactly 90 days + {"4 months", 120, ">4mo"}, + {"6 months", 180, ">6mo"}, + {"11 months", 330, ">11mo"}, + {"just under 1 year", 364, ">12mo"}, + {"exactly 1 year", 365, ">1yr"}, + {"18 months", 548, ">1yr"}, // Between 1 and 2 years + {"just under 2 years", 729, ">1yr"}, + {"exactly 2 years", 730, ">2yr"}, + {"3 years", 1095, ">3yr"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var lastAccess time.Time + if tt.daysAgo >= 0 { + // Create a time that is tt.daysAgo days in the past + lastAccess = time.Now().AddDate(0, 0, -tt.daysAgo) + } + // If daysAgo < 0, lastAccess remains zero value + + got := formatUnusedTime(lastAccess) + if got != tt.want { + t.Errorf("formatUnusedTime(%d days ago) = %q, want %q", tt.daysAgo, got, tt.want) + } + }) + } +} diff --git a/cmd/status/metrics_health.go b/cmd/status/metrics_health.go index 0e34828..b41ec68 100644 --- a/cmd/status/metrics_health.go +++ b/cmd/status/metrics_health.go @@ -69,7 +69,6 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d issues = append(issues, "High Memory") } - // Memory pressure penalty. // Memory pressure penalty. switch mem.Pressure { case "warn": From 8188f4b81beaa11b2f3289f29e19443360d289e9 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 22 Jan 2026 16:16:50 +0800 Subject: [PATCH 38/91] fix: clean update packages with SIP enabled --- lib/clean/system.sh | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/lib/clean/system.sh b/lib/clean/system.sh index a186c67..4381970 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -19,24 +19,22 @@ clean_deep_system() { safe_sudo_find_delete "/private/var/log" "*.gz" "$MOLE_LOG_AGE_DAYS" "f" || true log_success "System logs" if [[ -d "/Library/Updates" && ! -L "/Library/Updates" ]]; then - if ! is_sip_enabled; then - local updates_cleaned=0 - while IFS= read -r -d '' item; do - if [[ -z "$item" ]] || [[ ! "$item" =~ ^/Library/Updates/[^/]+$ ]]; then - debug_log "Skipping malformed path: $item" - continue - fi - local item_flags - item_flags=$($STAT_BSD -f%Sf "$item" 2> /dev/null || echo "") - if [[ "$item_flags" == *"restricted"* ]]; then - continue - fi - if safe_sudo_remove "$item"; then - ((updates_cleaned++)) - fi - done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) - [[ $updates_cleaned -gt 0 ]] && log_success "System library updates" - fi + local updates_cleaned=0 + while IFS= read -r -d '' item; do + if [[ -z "$item" ]] || [[ ! "$item" =~ ^/Library/Updates/[^/]+$ ]]; then + debug_log "Skipping malformed path: $item" + continue + fi + local item_flags + item_flags=$($STAT_BSD -f%Sf "$item" 2> /dev/null || echo "") + if [[ "$item_flags" == *"restricted"* ]]; then + continue + fi + if safe_sudo_remove "$item"; then + ((updates_cleaned++)) + fi + done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) + [[ $updates_cleaned -gt 0 ]] && log_success "System library updates" fi if [[ -d "/macOS Install Data" ]]; then local mtime=$(get_file_mtime "/macOS Install Data") From 507582c51debac8dcf954e7e677bb641823e1342 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 08:20:05 +0000 Subject: [PATCH 39/91] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 631 ++++++++++++++++++++++++----------------------- 1 file changed, 321 insertions(+), 310 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 75befa1..c141d5a 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -57,6 +57,17 @@ + + + + + + + + dwjoss + + + @@ -67,7 +78,7 @@ rubnogueira - + @@ -78,315 +89,7 @@ biplavbarua - - - - - - - - - bsisduck - - - - - - - - - - jimmystridh - - - - - - - - - - - fte-jjmartres - - - - - - - - - - - Else00 - - - - - - - - - - - carolyn-sun - - - - - - - - - - - MohammedEsafi - - - - - - - - - - - ndbroadbent - - - - - - - - - - - Sizk - - - - - - - - - - - thijsvanhal - - - - - - - - - - - yuzeguitarist - - - - - - - - - - - zeldrisho - - - - - - - - - - - bikraj2 - - - - - - - - - - - bunizao - - - - - - - - - - - frozturk - - - - - - - - - - - huyixi - - - - - - - - - - - purofle - - - - - - - - - - - NanmiCoder - - - - - - - - - - - Schlauer-Hax - - - - - - - - - - - anonymort - - - - - - - - - - - khipu-luke - - - - - - - - - - - LmanTW - - - - - - - - - - - kwakubiney - - - - - - - - - - - kowyo - - - - - - - - - - - jalen0x - - - - - - - - - - - Hensell - - - - - - - - - - - Copper-Eye - - - - - - - - - - - ClathW - - - - - - - - - - - andmev - - - @@ -397,4 +100,312 @@ iamxorum + + + + + + + + + bsisduck + + + + + + + + + + + jimmystridh + + + + + + + + + + + fte-jjmartres + + + + + + + + + + + Else00 + + + + + + + + + + + carolyn-sun + + + + + + + + + + + NanmiCoder + + + + + + + + + + + purofle + + + + + + + + + + + huyixi + + + + + + + + + + + frozturk + + + + + + + + + + + bunizao + + + + + + + + + + + bikraj2 + + + + + + + + + + + zeldrisho + + + + + + + + + + + yuzeguitarist + + + + + + + + + + + thijsvanhal + + + + + + + + + + + Sizk + + + + + + + + + + + ndbroadbent + + + + + + + + + + + MohammedEsafi + + + + + + + + + + + Schlauer-Hax + + + + + + + + + + + anonymort + + + + + + + + + + + khipu-luke + + + + + + + + + + + LmanTW + + + + + + + + + + + kwakubiney + + + + + + + + + + + kowyo + + + + + + + + + + + jalen0x + + + + + + + + + + + Hensell + + + + + + + + + + + Copper-Eye + + + + + + + + + + + ClathW + + + + + + + + + + + andmev + + \ No newline at end of file From dad15a5dc6012171cefeaf6fdfd35d9906af52ad Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 22 Jan 2026 17:10:18 +0800 Subject: [PATCH 40/91] clean: auto-enable system cleanup when sudo session is active --- bin/clean.sh | 63 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/bin/clean.sh b/bin/clean.sh index 74caadc..058c310 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -724,42 +724,53 @@ EOF fi if [[ -t 0 ]]; then - echo -ne "${PURPLE}${ICON_ARROW}${NC} System caches need sudo — ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: " - - local choice - choice=$(read_key) - - # ESC/Q aborts, Space skips, Enter enables system cleanup. - if [[ "$choice" == "QUIT" ]]; then - echo -e " ${GRAY}Canceled${NC}" - exit 0 - fi - - if [[ "$choice" == "SPACE" ]]; then - echo -e " ${GRAY}Skipped${NC}" + if sudo -n true 2> /dev/null; then + SYSTEM_CLEAN=true + echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access already available" echo "" - SYSTEM_CLEAN=false - elif [[ "$choice" == "ENTER" ]]; then - printf "\r\033[K" # Clear the prompt line - if ensure_sudo_session "System cleanup requires admin access"; then - SYSTEM_CLEAN=true - echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access granted" + else + echo -ne "${PURPLE}${ICON_ARROW}${NC} System caches need sudo — ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: " + + local choice + choice=$(read_key) + + # ESC/Q aborts, Space skips, Enter enables system cleanup. + if [[ "$choice" == "QUIT" ]]; then + echo -e " ${GRAY}Canceled${NC}" + exit 0 + fi + + if [[ "$choice" == "SPACE" ]]; then + echo -e " ${GRAY}Skipped${NC}" echo "" + SYSTEM_CLEAN=false + elif [[ "$choice" == "ENTER" ]]; then + printf "\r\033[K" # Clear the prompt line + if ensure_sudo_session "System cleanup requires admin access"; then + SYSTEM_CLEAN=true + echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access granted" + echo "" + else + SYSTEM_CLEAN=false + echo "" + echo -e "${YELLOW}Authentication failed${NC}, continuing with user-level cleanup" + fi else SYSTEM_CLEAN=false + echo -e " ${GRAY}Skipped${NC}" echo "" - echo -e "${YELLOW}Authentication failed${NC}, continuing with user-level cleanup" fi - else - SYSTEM_CLEAN=false - echo -e " ${GRAY}Skipped${NC}" - echo "" fi else - SYSTEM_CLEAN=false echo "" echo "Running in non-interactive mode" - echo " ${ICON_LIST} System-level cleanup skipped (requires interaction)" + if sudo -n true 2> /dev/null; then + SYSTEM_CLEAN=true + echo " ${ICON_LIST} System-level cleanup enabled (sudo session active)" + else + SYSTEM_CLEAN=false + echo " ${ICON_LIST} System-level cleanup skipped (requires sudo)" + fi echo " ${ICON_LIST} User-level cleanup will proceed automatically" echo "" fi From d862d5867253b7db5b41b6a4bc25946e1d290a38 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 22 Jan 2026 17:24:55 +0800 Subject: [PATCH 41/91] test: stabilize formatUnusedTime by using UTC baseline --- cmd/analyze/format_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/analyze/format_test.go b/cmd/analyze/format_test.go index de7022f..7cfdf57 100644 --- a/cmd/analyze/format_test.go +++ b/cmd/analyze/format_test.go @@ -310,6 +310,7 @@ func TestCalculateNameWidth(t *testing.T) { } func TestFormatUnusedTime(t *testing.T) { + now := time.Now().UTC() tests := []struct { name string daysAgo int @@ -334,8 +335,8 @@ func TestFormatUnusedTime(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var lastAccess time.Time if tt.daysAgo >= 0 { - // Create a time that is tt.daysAgo days in the past - lastAccess = time.Now().AddDate(0, 0, -tt.daysAgo) + // Use a fixed UTC baseline to avoid DST-related flakiness. + lastAccess = now.Add(-time.Duration(tt.daysAgo) * 24 * time.Hour) } // If daysAgo < 0, lastAccess remains zero value From 9d824d0ad2d8245370ef69cd6780af197f690a10 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 22 Jan 2026 17:45:53 +0800 Subject: [PATCH 42/91] fix: restore uninstall traps and remove dead search key --- lib/clean/apps.sh | 6 ------ lib/core/ui.sh | 1 - lib/uninstall/batch.sh | 29 ++++++++++++++++++++++++++--- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index 8aa0aea..1093f33 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -326,11 +326,6 @@ clean_orphaned_system_services() { start_section_spinner "Scanning orphaned system services..." - # Build list of installed app bundle IDs - local installed_bundles - installed_bundles=$(create_temp_file) - scan_installed_apps "$installed_bundles" - local orphaned_count=0 local total_orphaned_kb=0 local -a orphaned_files=() @@ -467,5 +462,4 @@ clean_orphaned_system_services() { note_activity fi - rm -f "$installed_bundles" } diff --git a/lib/core/ui.sh b/lib/core/ui.sh index f9a8ea0..ef44d92 100755 --- a/lib/core/ui.sh +++ b/lib/core/ui.sh @@ -220,7 +220,6 @@ read_key() { case "$key" in $'\n' | $'\r') echo "ENTER" ;; ' ') echo "SPACE" ;; - '/') echo "FILTER" ;; 'q' | 'Q') echo "QUIT" ;; 'R') echo "RETRY" ;; 'm' | 'M') echo "MORE" ;; diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 5084137..87014a1 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -169,15 +169,32 @@ remove_file_list() { batch_uninstall_applications() { local total_size_freed=0 - # Trap to clean up spinner and uninstall mode on interrupt - trap 'stop_inline_spinner 2>/dev/null; unset MOLE_UNINSTALL_MODE; echo ""; return 130' INT TERM - # shellcheck disable=SC2154 if [[ ${#selected_apps[@]} -eq 0 ]]; then log_warning "No applications selected for uninstallation" return 0 fi + local old_trap_int old_trap_term + old_trap_int=$(trap -p INT) + old_trap_term=$(trap -p TERM) + + _restore_uninstall_traps() { + if [[ -n "$old_trap_int" ]]; then + eval "$old_trap_int" + else + trap - INT + fi + if [[ -n "$old_trap_term" ]]; then + eval "$old_trap_term" + else + trap - TERM + fi + } + + # Trap to clean up spinner and uninstall mode on interrupt + trap 'stop_inline_spinner 2>/dev/null; unset MOLE_UNINSTALL_MODE; echo ""; _restore_uninstall_traps; return 130' INT TERM + # Pre-scan: running apps, sudo needs, size. local -a running_apps=() local -a sudo_apps=() @@ -348,6 +365,7 @@ batch_uninstall_applications() { $'\e' | q | Q) echo "" echo "" + _restore_uninstall_traps return 0 ;; "" | $'\n' | $'\r' | y | Y) @@ -356,6 +374,7 @@ batch_uninstall_applications() { *) echo "" echo "" + _restore_uninstall_traps return 0 ;; esac @@ -370,6 +389,7 @@ batch_uninstall_applications() { if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then echo "" log_error "Admin access denied" + _restore_uninstall_traps return 1 fi fi @@ -648,6 +668,9 @@ batch_uninstall_applications() { rm -f "$cache_file" 2> /dev/null || true fi + _restore_uninstall_traps + unset -f _restore_uninstall_traps + ((total_size_cleaned += total_size_freed)) unset failed_items } From cde39aaeb2b683f83e963ad7698a976a858ba2d6 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 22 Jan 2026 20:15:13 +0800 Subject: [PATCH 43/91] feat(clean): add large file review and unify warnings --- bin/clean.sh | 7 ++- lib/clean/system.sh | 86 ++++-------------------------------- lib/clean/user.sh | 98 +++++++++++++++++++++++++++++++++++++++++- lib/core/base.sh | 2 +- lib/uninstall/batch.sh | 2 +- 5 files changed, 113 insertions(+), 82 deletions(-) diff --git a/bin/clean.sh b/bin/clean.sh index 058c310..292f5b5 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -699,7 +699,7 @@ start_cleanup() { echo "" if [[ "$DRY_RUN" != "true" && -t 0 ]]; then - echo -e "${GRAY}${ICON_SOLID} Use --dry-run to preview, --whitelist to manage protected paths${NC}" + echo -e "${GRAY}${ICON_WARNING} Use --dry-run to preview, --whitelist to manage protected paths${NC}" fi if [[ "$DRY_RUN" == "true" ]]; then @@ -979,6 +979,11 @@ perform_cleanup() { clean_time_machine_failed_backups end_section + # ===== 16. Large files to review (report only) ===== + start_section "Large files to review" + check_large_file_candidates + end_section + # ===== Final summary ===== echo "" diff --git a/lib/clean/system.sh b/lib/clean/system.sh index 4381970..071eb4a 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -269,7 +269,7 @@ tm_is_running() { grep -qE '(^|[[:space:]])("Running"|Running)[[:space:]]*=[[:space:]]*1([[:space:]]*;|$)' <<< "$st" } -# Local APFS snapshots (keep the most recent). +# Local APFS snapshots (report only). clean_local_snapshots() { if ! command -v tmutil > /dev/null 2>&1; then return 0 @@ -279,93 +279,25 @@ clean_local_snapshots() { tm_is_running || rc_running=$? if [[ $rc_running -eq 2 ]]; then - echo -e " ${YELLOW}!${NC} Could not determine Time Machine status; skipping snapshot cleanup" + echo -e " ${YELLOW}!${NC} Could not determine Time Machine status; skipping snapshot check" return 0 fi if [[ $rc_running -eq 0 ]]; then - echo -e " ${YELLOW}!${NC} Time Machine is active; skipping snapshot cleanup" + echo -e " ${YELLOW}!${NC} Time Machine is active; skipping snapshot check" return 0 fi start_section_spinner "Checking local snapshots..." local snapshot_list - snapshot_list=$(tmutil listlocalsnapshots / 2> /dev/null) + snapshot_list=$(run_with_timeout 3 tmutil listlocalsnapshots / 2> /dev/null || true) stop_section_spinner [[ -z "$snapshot_list" ]] && return 0 - local cleaned_count=0 - local total_cleaned_size=0 # Estimation not possible without thin - local newest_ts=0 - local newest_name="" - local -a snapshots=() - while IFS= read -r line; do - if [[ "$line" =~ com\.apple\.TimeMachine\.([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{6}) ]]; then - local snap_name="${BASH_REMATCH[0]}" - snapshots+=("$snap_name") - local date_str="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]:0:2}:${BASH_REMATCH[4]:2:2}:${BASH_REMATCH[4]:4:2}" - local snap_ts=$(date -j -f "%Y-%m-%d %H:%M:%S" "$date_str" "+%s" 2> /dev/null || echo "0") - [[ "$snap_ts" == "0" ]] && continue - if [[ "$snap_ts" -gt "$newest_ts" ]]; then - newest_ts="$snap_ts" - newest_name="$snap_name" - fi - fi - done <<< "$snapshot_list" - [[ ${#snapshots[@]} -eq 0 ]] && return 0 - [[ -z "$newest_name" ]] && return 0 - - local deletable_count=$((${#snapshots[@]} - 1)) - [[ $deletable_count -le 0 ]] && return 0 - - if [[ "$DRY_RUN" != "true" ]]; then - if [[ ! -t 0 ]]; then - echo -e " ${YELLOW}!${NC} ${#snapshots[@]} local snapshot(s) found, skipping non-interactive mode" - echo -e " ${GRAY}${ICON_WARNING}${NC} ${GRAY}Tip: Snapshots may cause Disk Utility to show different 'Available' values${NC}" - return 0 - fi - echo -e " ${YELLOW}!${NC} Time Machine local snapshots found" - echo -e " ${GRAY}macOS can recreate them if needed.${NC}" - echo -e " ${GRAY}The most recent snapshot will be kept.${NC}" - echo -ne " ${PURPLE}${ICON_ARROW}${NC} Remove all local snapshots except the most recent one? ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: " - local choice - if type read_key > /dev/null 2>&1; then - choice=$(read_key) - else - IFS= read -r -s -n 1 choice || choice="" - if [[ -z "$choice" || "$choice" == $'\n' || "$choice" == $'\r' ]]; then - choice="ENTER" - fi - fi - if [[ "$choice" == "ENTER" ]]; then - printf "\r\033[K" # Clear the prompt line - else - echo -e " ${GRAY}Skipped${NC}" - return 0 - fi - fi - - local snap_name - for snap_name in "${snapshots[@]}"; do - if [[ "$snap_name" =~ com\.apple\.TimeMachine\.([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{6}) ]]; then - if [[ "${BASH_REMATCH[0]}" != "$newest_name" ]]; then - if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Local snapshot: $snap_name ${YELLOW}dry-run${NC}" - ((cleaned_count++)) - note_activity - else - if sudo tmutil deletelocalsnapshots "${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]}-${BASH_REMATCH[4]}" > /dev/null 2>&1; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed snapshot: $snap_name" - ((cleaned_count++)) - note_activity - else - echo -e " ${YELLOW}!${NC} Failed to remove: $snap_name" - fi - fi - fi - fi - done - if [[ $cleaned_count -gt 0 && "$DRY_RUN" != "true" ]]; then - log_success "Cleaned $cleaned_count local snapshots, kept latest" + local snapshot_count + snapshot_count=$(echo "$snapshot_list" | grep -Eo 'com\.apple\.TimeMachine\.[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}' | wc -l | awk '{print $1}') + if [[ "$snapshot_count" =~ ^[0-9]+$ && "$snapshot_count" -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Time Machine local snapshots: ${GREEN}${snapshot_count}${NC}${GRAY}, Review: tmutil listlocalsnapshots /${NC}" + note_activity fi } diff --git a/lib/clean/user.sh b/lib/clean/user.sh index c457f65..1ba543c 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -600,13 +600,107 @@ check_ios_device_backups() { local backup_human=$(command du -sh "$backup_dir" 2> /dev/null | awk '{print $1}') if [[ -n "$backup_human" ]]; then note_activity - echo -e " Found ${GREEN}${backup_human}${NC} iOS backups" - echo -e " You can delete them manually: ${backup_dir}" + echo -e " ${YELLOW}${ICON_WARNING}${NC} iOS backups: ${GREEN}${backup_human}${NC}${GRAY}, Path: $backup_dir${NC}" fi fi fi return 0 } + +# Large file candidates (report only, no deletion). +check_large_file_candidates() { + local threshold_kb=$((1024 * 1024)) # 1GB + local found_any=false + + local mail_dir="$HOME/Library/Mail" + if [[ -d "$mail_dir" ]]; then + local mail_kb + mail_kb=$(get_path_size_kb "$mail_dir") + if [[ "$mail_kb" -ge "$threshold_kb" ]]; then + local mail_human + mail_human=$(bytes_to_human "$((mail_kb * 1024))") + echo -e " ${YELLOW}${ICON_WARNING}${NC} Mail data: ${GREEN}${mail_human}${NC}${GRAY}, Path: $mail_dir${NC}" + found_any=true + fi + fi + + local mail_downloads="$HOME/Library/Mail Downloads" + if [[ -d "$mail_downloads" ]]; then + local downloads_kb + downloads_kb=$(get_path_size_kb "$mail_downloads") + if [[ "$downloads_kb" -ge "$threshold_kb" ]]; then + local downloads_human + downloads_human=$(bytes_to_human "$((downloads_kb * 1024))") + echo -e " ${YELLOW}${ICON_WARNING}${NC} Mail downloads: ${GREEN}${downloads_human}${NC}${GRAY}, Path: $mail_downloads${NC}" + found_any=true + fi + fi + + local installer_path + for installer_path in /Applications/Install\ macOS*.app; do + if [[ -e "$installer_path" ]]; then + local installer_kb + installer_kb=$(get_path_size_kb "$installer_path") + if [[ "$installer_kb" -gt 0 ]]; then + local installer_human + installer_human=$(bytes_to_human "$((installer_kb * 1024))") + echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS installer: ${GREEN}${installer_human}${NC}${GRAY}, Path: $installer_path${NC}" + found_any=true + fi + fi + done + + local updates_dir="$HOME/Library/Updates" + if [[ -d "$updates_dir" ]]; then + local updates_kb + updates_kb=$(get_path_size_kb "$updates_dir") + if [[ "$updates_kb" -ge "$threshold_kb" ]]; then + local updates_human + updates_human=$(bytes_to_human "$((updates_kb * 1024))") + echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS updates cache: ${GREEN}${updates_human}${NC}${GRAY}, Path: $updates_dir${NC}" + found_any=true + fi + fi + + if [[ "${SYSTEM_CLEAN:-false}" != "true" ]] && command -v tmutil > /dev/null 2>&1; then + local snapshot_list snapshot_count + snapshot_list=$(run_with_timeout 3 tmutil listlocalsnapshots / 2> /dev/null || true) + if [[ -n "$snapshot_list" ]]; then + snapshot_count=$(echo "$snapshot_list" | grep -Eo 'com\.apple\.TimeMachine\.[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}' | wc -l | awk '{print $1}') + if [[ "$snapshot_count" =~ ^[0-9]+$ && "$snapshot_count" -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Time Machine local snapshots: ${GREEN}${snapshot_count}${NC}${GRAY}, Review: tmutil listlocalsnapshots /${NC}" + found_any=true + fi + fi + fi + + if command -v docker > /dev/null 2>&1; then + local docker_output + docker_output=$(run_with_timeout 3 docker system df --format '{{.Type}}\t{{.Size}}\t{{.Reclaimable}}' 2> /dev/null || true) + if [[ -n "$docker_output" ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Docker storage:" + while IFS=$'\t' read -r dtype dsize dreclaim; do + [[ -z "$dtype" ]] && continue + echo -e " ${GRAY}• $dtype: $dsize, Reclaimable: $dreclaim${NC}" + done <<< "$docker_output" + found_any=true + else + docker_output=$(run_with_timeout 3 docker system df 2> /dev/null || true) + if [[ -n "$docker_output" ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Docker storage:" + echo -e " ${GRAY}• Run: docker system df${NC}" + found_any=true + fi + fi + fi + + if [[ "$found_any" == "false" ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} No large items detected in common locations" + fi + + note_activity + return 0 +} # Apple Silicon specific caches (IS_M_SERIES). clean_apple_silicon_caches() { if [[ "${IS_M_SERIES:-false}" != "true" ]]; then diff --git a/lib/core/base.sh b/lib/core/base.sh index 99139ee..188f6ba 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -31,7 +31,7 @@ readonly ICON_CONFIRM="◎" readonly ICON_ADMIN="⚙" readonly ICON_SUCCESS="✓" readonly ICON_ERROR="☻" -readonly ICON_WARNING="●" +readonly ICON_WARNING="☉" readonly ICON_EMPTY="○" readonly ICON_SOLID="●" readonly ICON_LIST="•" diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 87014a1..85cc952 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -332,7 +332,7 @@ batch_uninstall_applications() { while IFS= read -r file; do if [[ -n "$file" && -e "$file" ]]; then if [[ $sys_file_count -lt $max_files ]]; then - echo -e " ${BLUE}${ICON_SOLID}${NC} System: $file" + echo -e " ${BLUE}${ICON_WARNING}${NC} System: $file" fi ((sys_file_count++)) fi From be2e9ddeca81d5d0b05a562f15a83af933ed2491 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 23 Jan 2026 09:33:12 +0800 Subject: [PATCH 44/91] change reminder icon to be more comfortable --- lib/core/base.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/core/base.sh b/lib/core/base.sh index 188f6ba..92ae743 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -31,7 +31,7 @@ readonly ICON_CONFIRM="◎" readonly ICON_ADMIN="⚙" readonly ICON_SUCCESS="✓" readonly ICON_ERROR="☻" -readonly ICON_WARNING="☉" +readonly ICON_WARNING="◎" readonly ICON_EMPTY="○" readonly ICON_SOLID="●" readonly ICON_LIST="•" From 340a71056c769168f831e55f3421c51babe36f5b Mon Sep 17 00:00:00 2001 From: Andrei Murariu <83287213+iamxorum@users.noreply.github.com> Date: Fri, 23 Jan 2026 03:40:13 +0200 Subject: [PATCH 45/91] bug-fix: `mo uninstall` unbound variable on empty tuples (#352) This happens when no third party app is available to be uninstalled; this triggers an `unbound variable` on the app_data_tuple causing the utility to fail. This is caused mostly because the bash version is 3.2 on MacOS; it seems that since Bash 4 this unbound_variable is set to 0 and would have not went to that error. Added an alternative value in case of error of 0 for this topic. This should handle this corner case --- bin/uninstall.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 389e222..2f83bc5 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -137,9 +137,15 @@ scan_applications() { done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null) done + if [[ ${#app_data_tuples[@]:-0} -eq 0 ]]; then + rm -f "$temp_file" + printf "\r\033[K" >&2 + echo "No applications found to uninstall." >&2 + return 1 + fi # Pass 2: metadata + size in parallel (mdls is slow). local app_count=0 - local total_apps=${#app_data_tuples[@]} + local total_apps=${#app_data_tuples[@]:-0} local max_parallel max_parallel=$(get_optimal_parallel_jobs "io") if [[ $max_parallel -lt 8 ]]; then From 7e40824fa30311c69fb994cbe14b88561ba3942e Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 23 Jan 2026 09:43:04 +0800 Subject: [PATCH 46/91] optimize code syntax --- bin/uninstall.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 2f83bc5..94cbf66 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -137,7 +137,7 @@ scan_applications() { done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null) done - if [[ ${#app_data_tuples[@]:-0} -eq 0 ]]; then + if [[ ${#app_data_tuples[@]} -eq 0 ]]; then rm -f "$temp_file" printf "\r\033[K" >&2 echo "No applications found to uninstall." >&2 @@ -145,7 +145,7 @@ scan_applications() { fi # Pass 2: metadata + size in parallel (mdls is slow). local app_count=0 - local total_apps=${#app_data_tuples[@]:-0} + local total_apps=${#app_data_tuples[@]} local max_parallel max_parallel=$(get_optimal_parallel_jobs "io") if [[ $max_parallel -lt 8 ]]; then From 5a8d766667c2d12b21af9f0262376e35160d96e8 Mon Sep 17 00:00:00 2001 From: Shakeel Mohamed Date: Thu, 22 Jan 2026 17:55:02 -0800 Subject: [PATCH 47/91] Add Puppeteer cache cleanup (#353) --- lib/clean/user.sh | 48 ++++++++++++++++++++++++++++++++++++++ tests/clean_user_core.bats | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 1ba543c..30533d6 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -12,6 +12,53 @@ clean_user_essentials() { fi } +clean_puppeteer_cache() { + local puppeteer_cache="$HOME/.cache/puppeteer" + [[ -d "$puppeteer_cache" ]] || return 0 + + local size_kb + size_kb=$(get_path_size_kb "$puppeteer_cache" || echo 0) + size_kb="${size_kb:-0}" + + if [[ $size_kb -le 0 ]]; then + return 0 + fi + + local size_human + size_human=$(bytes_to_human "$((size_kb * 1024))") + + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Puppeteer browser cache ${YELLOW}($size_human dry)${NC}" + note_activity + return 0 + fi + + if [[ -t 0 ]]; then + echo "" + echo -ne "${PURPLE}${ICON_ARROW}${NC} Remove Puppeteer cache ($size_human)? ${GREEN}Enter${NC} yes, ${GRAY}Space${NC} skip: " + + local choice + choice=$(read_key) + + if [[ "$choice" == "QUIT" ]]; then + echo -e " ${GRAY}Skipped${NC}" + return 0 + fi + + if [[ "$choice" == "SPACE" ]]; then + echo -e " ${GRAY}Skipped${NC}" + return 0 + elif [[ "$choice" == "ENTER" ]]; then + printf "\r\033[K" + else + echo -e " ${GRAY}Skipped${NC}" + return 0 + fi + fi + + safe_clean "$puppeteer_cache" "Puppeteer browser cache" +} + # Remove old Google Chrome versions while keeping Current. clean_chrome_old_versions() { local -a app_paths=( @@ -446,6 +493,7 @@ clean_browsers() { safe_clean ~/Library/Application\ Support/Google/Chrome/*/Application\ Cache/* "Chrome app cache" safe_clean ~/Library/Application\ Support/Google/Chrome/*/GPUCache/* "Chrome GPU cache" safe_clean ~/Library/Caches/Chromium/* "Chromium cache" + clean_puppeteer_cache safe_clean ~/Library/Caches/com.microsoft.edgemac/* "Edge cache" safe_clean ~/Library/Caches/company.thebrowser.Browser/* "Arc cache" safe_clean ~/Library/Caches/company.thebrowser.dia/* "Dia cache" diff --git a/tests/clean_user_core.bats b/tests/clean_user_core.bats index 97b4cba..9ea3475 100644 --- a/tests/clean_user_core.bats +++ b/tests/clean_user_core.bats @@ -110,6 +110,7 @@ EOF set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" +mkdir -p "$HOME/.cache/puppeteer" safe_clean() { echo "$2"; } clean_browsers EOF @@ -119,6 +120,51 @@ EOF [[ "$output" == *"Firefox cache"* ]] } +@test "clean_puppeteer_cache cleans when cache exists in non-interactive mode" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +mkdir -p "$HOME/.cache/puppeteer" +echo "test" > "$HOME/.cache/puppeteer/chrome-1234.zip" +safe_clean() { echo "$2"; } +clean_puppeteer_cache +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Puppeteer browser cache"* ]] +} + +@test "clean_puppeteer_cache skips when cache does not exist" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +safe_clean() { echo "$2"; } +rm -rf "$HOME/.cache/puppeteer" +clean_puppeteer_cache +EOF + + [ "$status" -eq 0 ] + [[ -z "${output//[[:space:]]/}" ]] +} + +@test "clean_puppeteer_cache respects DRY_RUN mode" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +mkdir -p "$HOME/.cache/puppeteer" +echo "test" > "$HOME/.cache/puppeteer/chrome-1234.zip" +note_activity() { :; } +clean_puppeteer_cache +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Puppeteer browser cache"* ]] + [[ "$output" == *"dry"* ]] +} + @test "clean_application_support_logs skips when no access" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail From 0cebdea78e456e43de4bac942ad369840cec93b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 01:55:21 +0000 Subject: [PATCH 48/91] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 239 +++++++++++++++++++++++++---------------------- 1 file changed, 125 insertions(+), 114 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index c141d5a..26f9098 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -68,6 +68,17 @@ + + + + + + + + iamxorum + + + @@ -78,7 +89,7 @@ rubnogueira - + @@ -89,17 +100,6 @@ biplavbarua - - - - - - - - - iamxorum - - @@ -157,104 +157,27 @@ - + - - - NanmiCoder + + + ndbroadbent - + - - - purofle + + + shakeelmohamed - - - - - - - - huyixi - - - - - - - - - - - frozturk - - - - - - - - - - - bunizao - - - - - - - - - - - bikraj2 - - - - - - - - - - - zeldrisho - - - - - - - - - - - yuzeguitarist - - - - - - - - - - - thijsvanhal - - - @@ -265,18 +188,106 @@ Sizk - + - + - - - ndbroadbent + + + thijsvanhal + + + + + + + + + + + yuzeguitarist + + + + + + + + + + + zeldrisho + + + + + + + + + + + bikraj2 + + + + + + + + + + + bunizao + + + + + + + + + + + frozturk + + + + + + + + + + + huyixi + + + + + + + + + + + purofle + + + + + + + + NanmiCoder + + + @@ -287,7 +298,7 @@ MohammedEsafi - + @@ -298,7 +309,7 @@ Schlauer-Hax - + @@ -309,7 +320,7 @@ anonymort - + @@ -320,7 +331,7 @@ khipu-luke - + @@ -331,7 +342,7 @@ LmanTW - + @@ -342,7 +353,7 @@ kwakubiney - + @@ -353,7 +364,7 @@ kowyo - + @@ -364,7 +375,7 @@ jalen0x - + @@ -375,7 +386,7 @@ Hensell - + @@ -386,7 +397,7 @@ Copper-Eye - + @@ -397,7 +408,7 @@ ClathW - + From 1ad7d7e79b62eb24425616ead04fbeaeacc70298 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 23 Jan 2026 09:56:14 +0800 Subject: [PATCH 49/91] refactor: simplify Puppeteer cache cleanup to match browser cache pattern - Replace 48-line custom function with one-line safe_clean call - Remove unnecessary interactive confirmation (consistent with other browser caches) - Simplify tests to verify Puppeteer cache is in clean_browsers output - Net: -93 lines of code for same functionality --- lib/clean/user.sh | 49 +------------------------------------- tests/clean_user_core.bats | 45 ---------------------------------- 2 files changed, 1 insertion(+), 93 deletions(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 30533d6..1248c0d 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -12,53 +12,6 @@ clean_user_essentials() { fi } -clean_puppeteer_cache() { - local puppeteer_cache="$HOME/.cache/puppeteer" - [[ -d "$puppeteer_cache" ]] || return 0 - - local size_kb - size_kb=$(get_path_size_kb "$puppeteer_cache" || echo 0) - size_kb="${size_kb:-0}" - - if [[ $size_kb -le 0 ]]; then - return 0 - fi - - local size_human - size_human=$(bytes_to_human "$((size_kb * 1024))") - - if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Puppeteer browser cache ${YELLOW}($size_human dry)${NC}" - note_activity - return 0 - fi - - if [[ -t 0 ]]; then - echo "" - echo -ne "${PURPLE}${ICON_ARROW}${NC} Remove Puppeteer cache ($size_human)? ${GREEN}Enter${NC} yes, ${GRAY}Space${NC} skip: " - - local choice - choice=$(read_key) - - if [[ "$choice" == "QUIT" ]]; then - echo -e " ${GRAY}Skipped${NC}" - return 0 - fi - - if [[ "$choice" == "SPACE" ]]; then - echo -e " ${GRAY}Skipped${NC}" - return 0 - elif [[ "$choice" == "ENTER" ]]; then - printf "\r\033[K" - else - echo -e " ${GRAY}Skipped${NC}" - return 0 - fi - fi - - safe_clean "$puppeteer_cache" "Puppeteer browser cache" -} - # Remove old Google Chrome versions while keeping Current. clean_chrome_old_versions() { local -a app_paths=( @@ -493,7 +446,7 @@ clean_browsers() { safe_clean ~/Library/Application\ Support/Google/Chrome/*/Application\ Cache/* "Chrome app cache" safe_clean ~/Library/Application\ Support/Google/Chrome/*/GPUCache/* "Chrome GPU cache" safe_clean ~/Library/Caches/Chromium/* "Chromium cache" - clean_puppeteer_cache + safe_clean ~/.cache/puppeteer/* "Puppeteer browser cache" safe_clean ~/Library/Caches/com.microsoft.edgemac/* "Edge cache" safe_clean ~/Library/Caches/company.thebrowser.Browser/* "Arc cache" safe_clean ~/Library/Caches/company.thebrowser.dia/* "Dia cache" diff --git a/tests/clean_user_core.bats b/tests/clean_user_core.bats index 9ea3475..f4d0515 100644 --- a/tests/clean_user_core.bats +++ b/tests/clean_user_core.bats @@ -110,7 +110,6 @@ EOF set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" -mkdir -p "$HOME/.cache/puppeteer" safe_clean() { echo "$2"; } clean_browsers EOF @@ -118,53 +117,9 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Safari cache"* ]] [[ "$output" == *"Firefox cache"* ]] -} - -@test "clean_puppeteer_cache cleans when cache exists in non-interactive mode" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -mkdir -p "$HOME/.cache/puppeteer" -echo "test" > "$HOME/.cache/puppeteer/chrome-1234.zip" -safe_clean() { echo "$2"; } -clean_puppeteer_cache -EOF - - [ "$status" -eq 0 ] [[ "$output" == *"Puppeteer browser cache"* ]] } -@test "clean_puppeteer_cache skips when cache does not exist" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -safe_clean() { echo "$2"; } -rm -rf "$HOME/.cache/puppeteer" -clean_puppeteer_cache -EOF - - [ "$status" -eq 0 ] - [[ -z "${output//[[:space:]]/}" ]] -} - -@test "clean_puppeteer_cache respects DRY_RUN mode" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -mkdir -p "$HOME/.cache/puppeteer" -echo "test" > "$HOME/.cache/puppeteer/chrome-1234.zip" -note_activity() { :; } -clean_puppeteer_cache -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Puppeteer browser cache"* ]] - [[ "$output" == *"dry"* ]] -} - @test "clean_application_support_logs skips when no access" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail From e48943cd0fb60edcdbd1bc715c2d76068a341cbf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 01:58:17 +0000 Subject: [PATCH 50/91] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 255 +++++++++++++++++++++++------------------------ 1 file changed, 122 insertions(+), 133 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 26f9098..c141d5a 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -68,17 +68,6 @@ - - - - - - - - iamxorum - - - @@ -89,7 +78,7 @@ rubnogueira - + @@ -100,6 +89,17 @@ biplavbarua + + + + + + + + + iamxorum + + @@ -157,115 +157,16 @@ - + - - - ndbroadbent + + + NanmiCoder - - - - - - - - shakeelmohamed - - - - - - - - - - - Sizk - - - - - - - - - - - thijsvanhal - - - - - - - - - - - yuzeguitarist - - - - - - - - - - - zeldrisho - - - - - - - - - - - bikraj2 - - - - - - - - - - - bunizao - - - - - - - - - - - frozturk - - - - - - - - - - - huyixi - - - @@ -276,18 +177,106 @@ purofle - + - + - - - NanmiCoder + + + huyixi - + + + + + + + + + frozturk + + + + + + + + + + + bunizao + + + + + + + + + + + bikraj2 + + + + + + + + + + + zeldrisho + + + + + + + + + + + yuzeguitarist + + + + + + + + + + + thijsvanhal + + + + + + + + + + + Sizk + + + + + + + + + + + ndbroadbent + + + @@ -298,7 +287,7 @@ MohammedEsafi - + @@ -309,7 +298,7 @@ Schlauer-Hax - + @@ -320,7 +309,7 @@ anonymort - + @@ -331,7 +320,7 @@ khipu-luke - + @@ -342,7 +331,7 @@ LmanTW - + @@ -353,7 +342,7 @@ kwakubiney - + @@ -364,7 +353,7 @@ kowyo - + @@ -375,7 +364,7 @@ jalen0x - + @@ -386,7 +375,7 @@ Hensell - + @@ -397,7 +386,7 @@ Copper-Eye - + @@ -408,7 +397,7 @@ ClathW - + From 8c9cd1f90c6c337548122eb549b1ed0976539e7d Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 23 Jan 2026 17:37:37 +0800 Subject: [PATCH 51/91] feat(clean): add JetBrains Toolbox old IDE cleanup with whitelist-safe handling --- lib/clean/dev.sh | 98 +++++++++++++++++++++++++++++++++++++++++ tests/dev_extended.bats | 55 +++++++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index 223a380..795480f 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -213,6 +213,103 @@ clean_dev_jvm() { safe_clean ~/.sbt/* "SBT cache" safe_clean ~/.ivy2/cache/* "Ivy cache" } +# JetBrains Toolbox old IDE versions (keep current + recent backup). +clean_dev_jetbrains_toolbox() { + local toolbox_root="$HOME/Library/Application Support/JetBrains/Toolbox/apps" + [[ -d "$toolbox_root" ]] || return 0 + + local keep_previous="${MOLE_JETBRAINS_TOOLBOX_KEEP:-1}" + if [[ ! "$keep_previous" =~ ^[0-9]+$ ]]; then + keep_previous=1 + fi + + local whitelist_overridden="false" + local -a original_whitelist=() + if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then + original_whitelist=("${WHITELIST_PATTERNS[@]}") + local -a filtered_whitelist=() + local pattern + for pattern in "${WHITELIST_PATTERNS[@]}"; do + if [[ "$toolbox_root" == $pattern || "$pattern" == "$toolbox_root"* ]]; then + continue + fi + filtered_whitelist+=("$pattern") + done + WHITELIST_PATTERNS=("${filtered_whitelist[@]+${filtered_whitelist[@]}}") + whitelist_overridden="true" + fi + + local -a product_dirs=() + while IFS= read -r -d '' product_dir; do + product_dirs+=("$product_dir") + done < <(command find "$toolbox_root" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) + + local product_dir + for product_dir in "${product_dirs[@]}"; do + while IFS= read -r -d '' channel_dir; do + local current_link="" + local current_real="" + if [[ -L "$channel_dir/current" ]]; then + current_link=$(readlink "$channel_dir/current" 2> /dev/null || true) + if [[ -n "$current_link" ]]; then + if [[ "$current_link" == /* ]]; then + current_real="$current_link" + else + current_real="$channel_dir/$current_link" + fi + fi + elif [[ -d "$channel_dir/current" ]]; then + current_real="$channel_dir/current" + fi + + local -a version_dirs=() + while IFS= read -r -d '' version_dir; do + local name + name=$(basename "$version_dir") + + [[ "$name" == "current" ]] && continue + [[ "$name" == .* ]] && continue + [[ "$name" == "plugins" || "$name" == "plugins-lib" || "$name" == "plugins-libs" ]] && continue + [[ -n "$current_real" && "$version_dir" == "$current_real" ]] && continue + [[ ! "$name" =~ ^[0-9] ]] && continue + + version_dirs+=("$version_dir") + done < <(command find "$channel_dir" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) + + local -a sorted_dirs=() + while IFS= read -r line; do + local dir_path="${line#* }" + sorted_dirs+=("$dir_path") + done < <( + for version_dir in "${version_dirs[@]}"; do + local mtime + mtime=$(stat -f%m "$version_dir" 2> /dev/null || echo "0") + printf '%s %s\n' "$mtime" "$version_dir" + done | sort -rn + ) + + if [[ ${#sorted_dirs[@]} -le "$keep_previous" ]]; then + continue + fi + + local idx=0 + local dir_path + for dir_path in "${sorted_dirs[@]}"; do + if [[ $idx -lt $keep_previous ]]; then + ((idx++)) + continue + fi + safe_clean "$dir_path" "JetBrains Toolbox old IDE version" + note_activity + ((idx++)) + done + done < <(command find "$product_dir" -mindepth 1 -maxdepth 1 -type d -name "ch-*" -print0 2> /dev/null) + done + + if [[ "$whitelist_overridden" == "true" ]]; then + WHITELIST_PATTERNS=("${original_whitelist[@]}") + fi +} # Other language tool caches. clean_dev_other_langs() { safe_clean ~/.bundle/cache/* "Ruby Bundler cache" @@ -342,6 +439,7 @@ clean_developer_tools() { clean_project_caches clean_dev_mobile clean_dev_jvm + clean_dev_jetbrains_toolbox clean_dev_other_langs clean_dev_cicd clean_dev_database diff --git a/tests/dev_extended.bats b/tests/dev_extended.bats index 8747fc0..f7a66ec 100644 --- a/tests/dev_extended.bats +++ b/tests/dev_extended.bats @@ -149,3 +149,58 @@ EOF [ "$status" -eq 0 ] [[ "$output" != *"Rust toolchains"* ]] } + +@test "clean_dev_jetbrains_toolbox cleans old versions and bypasses toolbox whitelist" { + local toolbox_channel="$HOME/Library/Application Support/JetBrains/Toolbox/apps/IDEA/ch-0" + mkdir -p "$toolbox_channel/241.1" "$toolbox_channel/241.2" "$toolbox_channel/241.3" + ln -s "241.3" "$toolbox_channel/current" + touch -t 202401010000 "$toolbox_channel/241.1" + touch -t 202402010000 "$toolbox_channel/241.2" + touch -t 202403010000 "$toolbox_channel/241.3" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +toolbox_root="$HOME/Library/Application Support/JetBrains/Toolbox/apps" +WHITELIST_PATTERNS=("$toolbox_root"* "$HOME/Library/Application Support/JetBrains*") +note_activity() { :; } +safe_clean() { + local target="$1" + for pattern in "${WHITELIST_PATTERNS[@]+${WHITELIST_PATTERNS[@]}}"; do + if [[ "$pattern" == "$toolbox_root"* ]]; then + echo "WHITELIST_NOT_REMOVED" + exit 1 + fi + done + echo "$target" +} +MOLE_JETBRAINS_TOOLBOX_KEEP=1 +clean_dev_jetbrains_toolbox +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"/241.1"* ]] + [[ "$output" != *"/241.2"* ]] +} + +@test "clean_dev_jetbrains_toolbox keeps current directory and removes older versions" { + local toolbox_channel="$HOME/Library/Application Support/JetBrains/Toolbox/apps/IDEA/ch-0" + mkdir -p "$toolbox_channel/241.1" "$toolbox_channel/241.2" "$toolbox_channel/current" + touch -t 202401010000 "$toolbox_channel/241.1" + touch -t 202402010000 "$toolbox_channel/241.2" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +safe_clean() { echo "$1"; } +MOLE_JETBRAINS_TOOLBOX_KEEP=1 +clean_dev_jetbrains_toolbox +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"/241.1"* ]] + [[ "$output" != *"/241.2"* ]] +} From eb86a0ae3706a6f15e5a4c06e6ad8ea61124327b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 09:38:06 +0000 Subject: [PATCH 52/91] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 239 +++++++++++++++++++++++++---------------------- 1 file changed, 125 insertions(+), 114 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index c141d5a..26f9098 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -68,6 +68,17 @@ + + + + + + + + iamxorum + + + @@ -78,7 +89,7 @@ rubnogueira - + @@ -89,17 +100,6 @@ biplavbarua - - - - - - - - - iamxorum - - @@ -157,104 +157,27 @@ - + - - - NanmiCoder + + + ndbroadbent - + - - - purofle + + + shakeelmohamed - - - - - - - - huyixi - - - - - - - - - - - frozturk - - - - - - - - - - - bunizao - - - - - - - - - - - bikraj2 - - - - - - - - - - - zeldrisho - - - - - - - - - - - yuzeguitarist - - - - - - - - - - - thijsvanhal - - - @@ -265,18 +188,106 @@ Sizk - + - + - - - ndbroadbent + + + thijsvanhal + + + + + + + + + + + yuzeguitarist + + + + + + + + + + + zeldrisho + + + + + + + + + + + bikraj2 + + + + + + + + + + + bunizao + + + + + + + + + + + frozturk + + + + + + + + + + + huyixi + + + + + + + + + + + purofle + + + + + + + + NanmiCoder + + + @@ -287,7 +298,7 @@ MohammedEsafi - + @@ -298,7 +309,7 @@ Schlauer-Hax - + @@ -309,7 +320,7 @@ anonymort - + @@ -320,7 +331,7 @@ khipu-luke - + @@ -331,7 +342,7 @@ LmanTW - + @@ -342,7 +353,7 @@ kwakubiney - + @@ -353,7 +364,7 @@ kowyo - + @@ -364,7 +375,7 @@ jalen0x - + @@ -375,7 +386,7 @@ Hensell - + @@ -386,7 +397,7 @@ Copper-Eye - + @@ -397,7 +408,7 @@ ClathW - + From d7a0d480bcdb3773099330c6f65bd2a24f9e21fe Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 23 Jan 2026 18:05:09 +0800 Subject: [PATCH 53/91] fix: improve dry-run mode and error handling - Export MOLE_DRY_RUN env var for subprocess visibility - Add || true to grep commands to prevent pipeline failures - Add dry-run test for clean_orphaned_system_services - Simplify clean_local_snapshots tests --- bin/clean.sh | 1 + lib/clean/system.sh | 2 +- lib/clean/user.sh | 2 +- tests/clean_apps.bats | 45 ++++++++++++++++ tests/clean_system_maintenance.bats | 80 ++++------------------------- 5 files changed, 59 insertions(+), 71 deletions(-) diff --git a/bin/clean.sh b/bin/clean.sh index 292f5b5..90e3932 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -1069,6 +1069,7 @@ main() { ;; "--dry-run" | "-n") DRY_RUN=true + export MOLE_DRY_RUN=1 ;; "--whitelist") source "$SCRIPT_DIR/../lib/manage/whitelist.sh" diff --git a/lib/clean/system.sh b/lib/clean/system.sh index 071eb4a..f06dd0c 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -295,7 +295,7 @@ clean_local_snapshots() { [[ -z "$snapshot_list" ]] && return 0 local snapshot_count - snapshot_count=$(echo "$snapshot_list" | grep -Eo 'com\.apple\.TimeMachine\.[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}' | wc -l | awk '{print $1}') + snapshot_count=$(echo "$snapshot_list" | { grep -Eo 'com\.apple\.TimeMachine\.[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}' || true; } | wc -l | awk '{print $1}') if [[ "$snapshot_count" =~ ^[0-9]+$ && "$snapshot_count" -gt 0 ]]; then echo -e " ${YELLOW}${ICON_WARNING}${NC} Time Machine local snapshots: ${GREEN}${snapshot_count}${NC}${GRAY}, Review: tmutil listlocalsnapshots /${NC}" note_activity diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 1248c0d..baf98ab 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -667,7 +667,7 @@ check_large_file_candidates() { local snapshot_list snapshot_count snapshot_list=$(run_with_timeout 3 tmutil listlocalsnapshots / 2> /dev/null || true) if [[ -n "$snapshot_list" ]]; then - snapshot_count=$(echo "$snapshot_list" | grep -Eo 'com\.apple\.TimeMachine\.[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}' | wc -l | awk '{print $1}') + snapshot_count=$(echo "$snapshot_list" | { grep -Eo 'com\.apple\.TimeMachine\.[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}' || true; } | wc -l | awk '{print $1}') if [[ "$snapshot_count" =~ ^[0-9]+$ && "$snapshot_count" -gt 0 ]]; then echo -e " ${YELLOW}${ICON_WARNING}${NC} Time Machine local snapshots: ${GREEN}${snapshot_count}${NC}${GRAY}, Review: tmutil listlocalsnapshots /${NC}" found_any=true diff --git a/tests/clean_apps.bats b/tests/clean_apps.bats index 7533315..8de3f71 100644 --- a/tests/clean_apps.bats +++ b/tests/clean_apps.bats @@ -115,3 +115,48 @@ EOF [[ "$output" == "ok" ]] } +@test "clean_orphaned_system_services respects dry-run" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.sogou.test.plist" +touch "$tmp_plist" + +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + if [[ "$1" == "find" ]]; then + printf '%s\0' "$tmp_plist" + return 0 + fi + if [[ "$1" == "du" ]]; then + echo "4 $tmp_plist" + return 0 + fi + if [[ "$1" == "launchctl" ]]; then + echo "launchctl-called" + return 0 + fi + if [[ "$1" == "rm" ]]; then + echo "rm-called" + return 0 + fi + command "$@" +} + +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"rm-called"* ]] + [[ "$output" != *"launchctl-called"* ]] +} diff --git a/tests/clean_system_maintenance.bats b/tests/clean_system_maintenance.bats index 7be620b..97f2b5a 100644 --- a/tests/clean_system_maintenance.bats +++ b/tests/clean_system_maintenance.bats @@ -108,105 +108,47 @@ EOF [[ "$output" == *"No incomplete backups found"* ]] } -@test "clean_local_snapshots skips in non-interactive mode" { +@test "clean_local_snapshots reports snapshot count" { run bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" -tmutil() { - if [[ "$1" == "listlocalsnapshots" ]]; then - printf '%s\n' \ - "com.apple.TimeMachine.2023-10-25-120000" \ - "com.apple.TimeMachine.2023-10-24-120000" - return 0 - fi - return 0 -} -start_section_spinner(){ :; } -stop_section_spinner(){ :; } -tm_is_running(){ return 1; } - -DRY_RUN="false" -clean_local_snapshots -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"skipping non-interactive mode"* ]] - [[ "$output" != *"Removed snapshot"* ]] -} - -@test "clean_local_snapshots keeps latest in dry-run" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/system.sh" - -tmutil() { - if [[ "$1" == "listlocalsnapshots" ]]; then - printf '%s\n' \ - "com.apple.TimeMachine.2023-10-25-120000" \ - "com.apple.TimeMachine.2023-10-25-130000" \ - "com.apple.TimeMachine.2023-10-24-120000" - return 0 - fi - return 0 +run_with_timeout() { + printf '%s\n' \ + "com.apple.TimeMachine.2023-10-25-120000" \ + "com.apple.TimeMachine.2023-10-24-120000" } start_section_spinner(){ :; } stop_section_spinner(){ :; } note_activity(){ :; } tm_is_running(){ return 1; } -DRY_RUN="true" clean_local_snapshots EOF [ "$status" -eq 0 ] - [[ "$output" == *"Local snapshot: com.apple.TimeMachine.2023-10-25-120000"* ]] - [[ "$output" == *"Local snapshot: com.apple.TimeMachine.2023-10-24-120000"* ]] - [[ "$output" != *"Local snapshot: com.apple.TimeMachine.2023-10-25-130000"* ]] + [[ "$output" == *"Time Machine local snapshots:"* ]] + [[ "$output" == *"tmutil listlocalsnapshots /"* ]] } -@test "clean_local_snapshots uses read fallback when read_key missing" { - if ! command -v script > /dev/null 2>&1; then - skip "script not available" - fi - - local tmp_script="$BATS_TEST_TMPDIR/clean_local_snapshots_fallback.sh" - cat > "$tmp_script" <<'EOF' +@test "clean_local_snapshots is quiet when no snapshots" { + run bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" -tmutil() { - if [[ "$1" == "listlocalsnapshots" ]]; then - printf '%s\n' \ - "com.apple.TimeMachine.2023-10-25-120000" \ - "com.apple.TimeMachine.2023-10-24-120000" - return 0 - fi - return 0 -} +run_with_timeout() { echo "Snapshots for disk /:"; } start_section_spinner(){ :; } stop_section_spinner(){ :; } note_activity(){ :; } tm_is_running(){ return 1; } -unset -f read_key - -CALL_LOG="$HOME/snapshot_calls.log" -> "$CALL_LOG" -sudo() { echo "sudo:$*" >> "$CALL_LOG"; return 0; } - -DRY_RUN="false" clean_local_snapshots -cat "$CALL_LOG" EOF - run bash --noprofile --norc -c "printf '\n' | script -q /dev/null bash \"$tmp_script\"" - [ "$status" -eq 0 ] - [[ "$output" == *"Skipped"* ]] + [[ "$output" != *"Time Machine local snapshots"* ]] } From 7e359c57d93d2c2b8748bc647e67a5685103543c Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 23 Jan 2026 18:27:05 +0800 Subject: [PATCH 54/91] fix(analyze): correct top list and thresholds --- cmd/analyze/constants.go | 4 ++-- cmd/analyze/scanner.go | 31 +++++++++++++++++++++---------- cmd/analyze/view.go | 2 +- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/cmd/analyze/constants.go b/cmd/analyze/constants.go index 7106087..7376daf 100644 --- a/cmd/analyze/constants.go +++ b/cmd/analyze/constants.go @@ -4,9 +4,9 @@ import "time" const ( maxEntries = 30 - maxLargeFiles = 30 + maxLargeFiles = 20 barWidth = 24 - minLargeFileSize = 100 << 20 + spotlightMinFileSize = 100 << 20 defaultViewport = 12 overviewCacheTTL = 7 * 24 * time.Hour overviewCacheFile = "overview_sizes.json" diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index 2f0f797..74909da 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -37,6 +37,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in largeFilesHeap := &largeFileHeap{} heap.Init(largeFilesHeap) + largeFileMinSize := int64(0) // Worker pool sized for I/O-bound scanning. numWorkers := max(runtime.NumCPU()*cpuMultiplier, minWorkers) @@ -84,9 +85,13 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in for file := range largeFileChan { if largeFilesHeap.Len() < maxLargeFiles { heap.Push(largeFilesHeap, file) + if largeFilesHeap.Len() == maxLargeFiles { + atomic.StoreInt64(&largeFileMinSize, (*largeFilesHeap)[0].Size) + } } else if file.Size > (*largeFilesHeap)[0].Size { heap.Pop(largeFilesHeap) heap.Push(largeFilesHeap, file) + atomic.StoreInt64(&largeFileMinSize, (*largeFilesHeap)[0].Size) } } }() @@ -148,7 +153,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in } else if cached, err := loadCacheFromDisk(path); err == nil { size = cached.TotalSize } else { - size = calculateDirSizeConcurrent(path, largeFileChan, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + size = calculateDirSizeConcurrent(path, largeFileChan, &largeFileMinSize, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) } atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) @@ -200,7 +205,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in defer wg.Done() defer func() { <-sem }() - size := calculateDirSizeConcurrent(path, largeFileChan, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + size := calculateDirSizeConcurrent(path, largeFileChan, &largeFileMinSize, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) @@ -233,8 +238,11 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in LastAccess: getLastAccessTimeFromInfo(info), } // Track large files only. - if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize { - largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size} + if !shouldSkipFileForLargeTracking(fullPath) { + minSize := atomic.LoadInt64(&largeFileMinSize) + if size >= minSize { + largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size} + } } } @@ -256,8 +264,8 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in largeFiles[i] = heap.Pop(largeFilesHeap).(fileEntry) } - // Use Spotlight for large files when available. - if spotlightFiles := findLargeFilesWithSpotlight(root, minLargeFileSize); len(spotlightFiles) > 0 { + // Use Spotlight for large files when it expands the list. + if spotlightFiles := findLargeFilesWithSpotlight(root, spotlightMinFileSize); len(spotlightFiles) > len(largeFiles) { largeFiles = spotlightFiles } @@ -430,7 +438,7 @@ func isInFoldedDir(path string) bool { return false } -func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, duSem, duQueueSem chan struct{}, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) int64 { +func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, largeFileMinSize *int64, duSem, duQueueSem chan struct{}, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) int64 { children, err := os.ReadDir(root) if err != nil { return 0 @@ -488,7 +496,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, duS defer wg.Done() defer func() { <-sem }() - size := calculateDirSizeConcurrent(path, largeFileChan, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + size := calculateDirSizeConcurrent(path, largeFileChan, largeFileMinSize, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) }(fullPath) @@ -505,8 +513,11 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, duS atomic.AddInt64(filesScanned, 1) atomic.AddInt64(bytesScanned, size) - if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize { - largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size} + if !shouldSkipFileForLargeTracking(fullPath) && largeFileMinSize != nil { + minSize := atomic.LoadInt64(largeFileMinSize) + if size >= minSize { + largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size} + } } // Update current path occasionally to prevent UI jitter. diff --git a/cmd/analyze/view.go b/cmd/analyze/view.go index b92678b..726f1a9 100644 --- a/cmd/analyze/view.go +++ b/cmd/analyze/view.go @@ -112,7 +112,7 @@ func (m model) View() string { if m.showLargeFiles { if len(m.largeFiles) == 0 { - fmt.Fprintln(&b, " No large files found (>=100MB)") + fmt.Fprintln(&b, " No large files found") } else { viewport := calculateViewport(m.height, true) start := max(m.largeOffset, 0) From 500b6dc554e8ef9c635d97983e301de84622e41e Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 23 Jan 2026 18:32:33 +0800 Subject: [PATCH 55/91] fix(clean): silence ShellCheck SC2053 --- lib/clean/dev.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index 795480f..3904706 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -230,7 +230,7 @@ clean_dev_jetbrains_toolbox() { local -a filtered_whitelist=() local pattern for pattern in "${WHITELIST_PATTERNS[@]}"; do - if [[ "$toolbox_root" == $pattern || "$pattern" == "$toolbox_root"* ]]; then + if [[ "$toolbox_root" == "$pattern" || "$pattern" == "$toolbox_root"* ]]; then continue fi filtered_whitelist+=("$pattern") From 83166f2fe80501895f545778db42caae6c7e83e4 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 23 Jan 2026 20:16:06 +0800 Subject: [PATCH 56/91] fix: avoid orphan service false positives and warm large file threshold --- cmd/analyze/constants.go | 25 ++++++++++--------- cmd/analyze/scanner.go | 2 +- lib/clean/apps.sh | 54 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/cmd/analyze/constants.go b/cmd/analyze/constants.go index 7376daf..fd6591b 100644 --- a/cmd/analyze/constants.go +++ b/cmd/analyze/constants.go @@ -3,18 +3,19 @@ package main import "time" const ( - maxEntries = 30 - maxLargeFiles = 20 - barWidth = 24 - spotlightMinFileSize = 100 << 20 - defaultViewport = 12 - overviewCacheTTL = 7 * 24 * time.Hour - overviewCacheFile = "overview_sizes.json" - duTimeout = 30 * time.Second - mdlsTimeout = 5 * time.Second - maxConcurrentOverview = 8 - batchUpdateSize = 100 - cacheModTimeGrace = 30 * time.Minute + maxEntries = 30 + maxLargeFiles = 20 + barWidth = 24 + spotlightMinFileSize = 100 << 20 + largeFileWarmupMinSize = 1 << 20 + defaultViewport = 12 + overviewCacheTTL = 7 * 24 * time.Hour + overviewCacheFile = "overview_sizes.json" + duTimeout = 30 * time.Second + mdlsTimeout = 5 * time.Second + maxConcurrentOverview = 8 + batchUpdateSize = 100 + cacheModTimeGrace = 30 * time.Minute // Worker pool limits. minWorkers = 16 diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index 74909da..efd55b6 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -37,7 +37,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in largeFilesHeap := &largeFileHeap{} heap.Init(largeFilesHeap) - largeFileMinSize := int64(0) + largeFileMinSize := int64(largeFileWarmupMinSize) // Worker pool sized for I/O-bound scanning. numWorkers := max(runtime.NumCPU()*cpuMultiplier, minWorkers) diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index 1093f33..3511b78 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -345,6 +345,50 @@ clean_orphaned_system_services() { "cn.i4tools.*:/Applications/i4Tools.app" ) + local mdfind_cache_file="" + _system_service_app_exists() { + local bundle_id="$1" + local app_path="$2" + + [[ -n "$app_path" && -d "$app_path" ]] && return 0 + + if [[ -n "$app_path" ]]; then + local app_name + app_name=$(basename "$app_path") + case "$app_path" in + /Applications/*) + [[ -d "$HOME/Applications/$app_name" ]] && return 0 + [[ -d "/Applications/Setapp/$app_name" ]] && return 0 + ;; + /Library/Input\ Methods/*) + [[ -d "$HOME/Library/Input Methods/$app_name" ]] && return 0 + ;; + esac + fi + + if [[ -n "$bundle_id" ]] && [[ "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]] && [[ ${#bundle_id} -ge 5 ]]; then + if [[ -z "$mdfind_cache_file" ]]; then + mdfind_cache_file=$(mktemp "${TMPDIR:-/tmp}/mole_mdfind_cache.XXXXXX") + register_temp_file "$mdfind_cache_file" + fi + + if grep -Fxq "FOUND:$bundle_id" "$mdfind_cache_file" 2> /dev/null; then + return 0 + fi + if ! grep -Fxq "NOTFOUND:$bundle_id" "$mdfind_cache_file" 2> /dev/null; then + local app_found + app_found=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "") + if [[ -n "$app_found" ]]; then + echo "FOUND:$bundle_id" >> "$mdfind_cache_file" + return 0 + fi + echo "NOTFOUND:$bundle_id" >> "$mdfind_cache_file" + fi + fi + + return 1 + } + # Scan system LaunchDaemons if [[ -d /Library/LaunchDaemons ]]; then while IFS= read -r -d '' plist; do @@ -364,6 +408,9 @@ clean_orphaned_system_services() { # shellcheck disable=SC2053 if [[ "$bundle_id" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then + if _system_service_app_exists "$bundle_id" "$app_path"; then + continue + fi orphaned_files+=("$plist") local size_kb size_kb=$(sudo du -sk "$plist" 2> /dev/null | awk '{print $1}' || echo "0") @@ -392,6 +439,9 @@ clean_orphaned_system_services() { # shellcheck disable=SC2053 if [[ "$bundle_id" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then + if _system_service_app_exists "$bundle_id" "$app_path"; then + continue + fi orphaned_files+=("$plist") local size_kb size_kb=$(sudo du -sk "$plist" 2> /dev/null | awk '{print $1}' || echo "0") @@ -408,6 +458,7 @@ clean_orphaned_system_services() { while IFS= read -r -d '' helper; do local filename filename=$(basename "$helper") + local bundle_id="$filename" # Skip Apple system files [[ "$filename" == com.apple.* ]] && continue @@ -418,6 +469,9 @@ clean_orphaned_system_services() { # shellcheck disable=SC2053 if [[ "$filename" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then + if _system_service_app_exists "$bundle_id" "$app_path"; then + continue + fi orphaned_files+=("$helper") local size_kb size_kb=$(sudo du -sk "$helper" 2> /dev/null | awk '{print $1}' || echo "0") From 3d985ebcb95c0201ed348f1155667c2108d5c684 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 23 Jan 2026 20:31:54 +0800 Subject: [PATCH 57/91] publish 1.23 --- mole | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mole b/mole index b249154..4f3c1b6 100755 --- a/mole +++ b/mole @@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh" trap cleanup_temp_files EXIT INT TERM # Version and update helpers -VERSION="1.22.1" +VERSION="1.23.0" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { From a27caab2b6f32290ff7e627428cd95498b996278 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 23 Jan 2026 20:45:21 +0800 Subject: [PATCH 58/91] update core --- tests/core_performance.bats | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/core_performance.bats b/tests/core_performance.bats index dfc4037..949fbe6 100644 --- a/tests/core_performance.bats +++ b/tests/core_performance.bats @@ -108,6 +108,7 @@ setup() { @test "get_invoking_user executes quickly" { local start end elapsed + local limit_ms="${MOLE_PERF_GET_INVOKING_USER_LIMIT_MS:-500}" start=$(date +%s%N) for i in {1..100}; do @@ -117,7 +118,7 @@ setup() { elapsed=$(( (end - start) / 1000000 )) - [ "$elapsed" -lt 200 ] + [ "$elapsed" -lt "$limit_ms" ] } @test "get_darwin_major caches correctly" { From a4f1f49fab6665526c9bea747c610bdc8c5e859f Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 23 Jan 2026 22:25:35 +0800 Subject: [PATCH 59/91] feat: improve version detection and error handling - Add fallback to mo --version when brew list fails - Add error handling for debug log write failures - Improve version extraction with multiple fallback strategies --- lib/core/common.sh | 2 ++ lib/core/log.sh | 4 +++- mole | 8 +++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/core/common.sh b/lib/core/common.sh index 21a427e..75da002 100755 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -79,6 +79,7 @@ update_via_homebrew() { if echo "$upgrade_output" | grep -q "already installed"; then local installed_version installed_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') + [[ -z "$installed_version" ]] && installed_version=$(mo --version 2> /dev/null | awk '/Mole version/ {print $3; exit}') echo "" echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version (${installed_version:-$current_version})" echo "" @@ -90,6 +91,7 @@ update_via_homebrew() { echo "$upgrade_output" | grep -Ev "^(==>|Updating Homebrew|Warning:)" || true local new_version new_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') + [[ -z "$new_version" ]] && new_version=$(mo --version 2> /dev/null | awk '/Mole version/ {print $3; exit}') echo "" echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-$current_version})" echo "" diff --git a/lib/core/log.sh b/lib/core/log.sh index d9dca13..124ee7b 100644 --- a/lib/core/log.sh +++ b/lib/core/log.sh @@ -180,7 +180,9 @@ log_system_info() { # Reset debug log file for this new session ensure_user_file "$DEBUG_LOG_FILE" - : > "$DEBUG_LOG_FILE" + if ! : > "$DEBUG_LOG_FILE" 2> /dev/null; then + echo -e "${YELLOW}${ICON_WARNING}${NC} Debug log not writable: $DEBUG_LOG_FILE" >&2 + fi # Start block in debug log file { diff --git a/mole b/mole index 4f3c1b6..ff365d3 100755 --- a/mole +++ b/mole @@ -346,7 +346,13 @@ update_mole() { if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then local new_version - new_version=$("$mole_path" --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + new_version=$(printf '%s\n' "$output" | sed -n 's/.*(\([^)]*\)).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$("$mole_path" --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + fi + if [[ -z "$new_version" ]]; then + new_version="$latest" + fi printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-unknown})" else printf '\n' From f9c33ac31784a57fbf5466d9c2699a30515c194b Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 23 Jan 2026 22:28:00 +0800 Subject: [PATCH 60/91] publish 1.23.1 --- mole | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/mole b/mole index ff365d3..a37b4c6 100755 --- a/mole +++ b/mole @@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh" trap cleanup_temp_files EXIT INT TERM # Version and update helpers -VERSION="1.23.0" +VERSION="1.23.1" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { @@ -336,6 +336,7 @@ update_mole() { process_install_output() { local output="$1" + local fallback_version="$2" if [[ -t 1 ]]; then stop_inline_spinner; fi local filtered_output @@ -346,12 +347,9 @@ update_mole() { if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then local new_version - new_version=$(printf '%s\n' "$output" | sed -n 's/.*(\([^)]*\)).*/\1/p' | head -1) + new_version=$("$mole_path" --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") if [[ -z "$new_version" ]]; then - new_version=$("$mole_path" --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") - fi - if [[ -z "$new_version" ]]; then - new_version="$latest" + new_version="$fallback_version" fi printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-unknown})" else @@ -366,10 +364,10 @@ update_mole() { config_dir="$HOME/.config/mole" fi if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" --update 2>&1); then - process_install_output "$install_output" + process_install_output "$install_output" "$latest" else if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" 2>&1); then - process_install_output "$install_output" + process_install_output "$install_output" "$latest" else if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" From b0f9761458cf07265fc2210b615a167b96098390 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 23 Jan 2026 22:57:48 +0800 Subject: [PATCH 61/91] test: add process_install_output test cases - Test install.sh success message with version - Test fallback version when no success message - Test empty output handling - Test avoiding wrong parentheses content extraction --- tests/update.bats | 147 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/tests/update.bats b/tests/update.bats index a33d058..b155150 100644 --- a/tests/update.bats +++ b/tests/update.bats @@ -233,3 +233,150 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Already on latest version"* ]] } + +@test "process_install_output shows install.sh success message with version" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +GREEN='\033[0;32m' +ICON_SUCCESS='✓' +NC='\033[0m' + +process_install_output() { + local output="$1" + local fallback_version="$2" + + local filtered_output + filtered_output=$(printf '%s\n' "$output" | sed '/^$/d') + if [[ -n "$filtered_output" ]]; then + printf '%s\n' "$filtered_output" + fi + + if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then + local new_version + new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + if [[ -z "$new_version" ]]; then + new_version="$fallback_version" + fi + printf '\n%s\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-unknown})" + fi +} + +output="Installing Mole... +✓ Updated to latest version (1.23.1)" +process_install_output "$output" "1.23.0" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Updated to latest version (1.23.1)"* ]] + [[ "$output" != *"1.23.0"* ]] +} + +@test "process_install_output uses fallback version when install.sh has no success message" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +GREEN='\033[0;32m' +ICON_SUCCESS='✓' +NC='\033[0m' + +process_install_output() { + local output="$1" + local fallback_version="$2" + + local filtered_output + filtered_output=$(printf '%s\n' "$output" | sed '/^$/d') + if [[ -n "$filtered_output" ]]; then + printf '%s\n' "$filtered_output" + fi + + if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then + local new_version + new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + if [[ -z "$new_version" ]]; then + new_version="$fallback_version" + fi + printf '\n%s\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-unknown})" + fi +} + +output="Installing Mole... +Installation completed" +process_install_output "$output" "1.23.1" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Installation completed"* ]] + [[ "$output" == *"Updated to latest version (1.23.1)"* ]] +} + +@test "process_install_output handles empty output with fallback version" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +GREEN='\033[0;32m' +ICON_SUCCESS='✓' +NC='\033[0m' + +process_install_output() { + local output="$1" + local fallback_version="$2" + + local filtered_output + filtered_output=$(printf '%s\n' "$output" | sed '/^$/d') + if [[ -n "$filtered_output" ]]; then + printf '%s\n' "$filtered_output" + fi + + if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then + local new_version + new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + if [[ -z "$new_version" ]]; then + new_version="$fallback_version" + fi + printf '\n%s\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-unknown})" + fi +} + +output="" +process_install_output "$output" "1.23.1" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Updated to latest version (1.23.1)"* ]] +} + +@test "process_install_output does not extract wrong parentheses content" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +GREEN='\033[0;32m' +ICON_SUCCESS='✓' +NC='\033[0m' + +process_install_output() { + local output="$1" + local fallback_version="$2" + + local filtered_output + filtered_output=$(printf '%s\n' "$output" | sed '/^$/d') + if [[ -n "$filtered_output" ]]; then + printf '%s\n' "$filtered_output" + fi + + if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then + local new_version + new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + if [[ -z "$new_version" ]]; then + new_version="$fallback_version" + fi + printf '\n%s\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-unknown})" + fi +} + +output="Downloading (progress: 100%) +Done" +process_install_output "$output" "1.23.1" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Downloading (progress: 100%)"* ]] + [[ "$output" == *"Updated to latest version (1.23.1)"* ]] + [[ "$output" != *"progress: 100%"* ]] || [[ "$output" == *"Downloading (progress: 100%)"* ]] +} From c0596f51e384f28b98d28fc8695bbf9d5d91d14f Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 23 Jan 2026 23:28:27 +0800 Subject: [PATCH 62/91] Fix update output version parsing --- mole | 7 +++++-- tests/update.bats | 30 +++++++++++++++++++++--------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/mole b/mole index a37b4c6..6edaf1f 100755 --- a/mole +++ b/mole @@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh" trap cleanup_temp_files EXIT INT TERM # Version and update helpers -VERSION="1.23.1" +VERSION="1.23.2" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { @@ -347,7 +347,10 @@ update_mole() { if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then local new_version - new_version=$("$mole_path" --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + new_version=$(printf '%s\n' "$output" | sed -n 's/.*(version \([^)]*\)).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$("$mole_path" --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + fi if [[ -z "$new_version" ]]; then new_version="$fallback_version" fi diff --git a/tests/update.bats b/tests/update.bats index b155150..be95475 100644 --- a/tests/update.bats +++ b/tests/update.bats @@ -244,7 +244,7 @@ NC='\033[0m' process_install_output() { local output="$1" local fallback_version="$2" - + local filtered_output filtered_output=$(printf '%s\n' "$output" | sed '/^$/d') if [[ -n "$filtered_output" ]]; then @@ -253,7 +253,10 @@ process_install_output() { if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then local new_version - new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + new_version=$(printf '%s\n' "$output" | sed -n 's/.*(version \([^)]*\)).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + fi if [[ -z "$new_version" ]]; then new_version="$fallback_version" fi @@ -262,7 +265,7 @@ process_install_output() { } output="Installing Mole... -✓ Updated to latest version (1.23.1)" +◎ Mole installed successfully (version 1.23.1)" process_install_output "$output" "1.23.0" EOF @@ -281,7 +284,7 @@ NC='\033[0m' process_install_output() { local output="$1" local fallback_version="$2" - + local filtered_output filtered_output=$(printf '%s\n' "$output" | sed '/^$/d') if [[ -n "$filtered_output" ]]; then @@ -290,7 +293,10 @@ process_install_output() { if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then local new_version - new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + new_version=$(printf '%s\n' "$output" | sed -n 's/.*(version \([^)]*\)).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + fi if [[ -z "$new_version" ]]; then new_version="$fallback_version" fi @@ -318,7 +324,7 @@ NC='\033[0m' process_install_output() { local output="$1" local fallback_version="$2" - + local filtered_output filtered_output=$(printf '%s\n' "$output" | sed '/^$/d') if [[ -n "$filtered_output" ]]; then @@ -327,7 +333,10 @@ process_install_output() { if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then local new_version - new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + new_version=$(printf '%s\n' "$output" | sed -n 's/.*(version \([^)]*\)).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + fi if [[ -z "$new_version" ]]; then new_version="$fallback_version" fi @@ -353,7 +362,7 @@ NC='\033[0m' process_install_output() { local output="$1" local fallback_version="$2" - + local filtered_output filtered_output=$(printf '%s\n' "$output" | sed '/^$/d') if [[ -n "$filtered_output" ]]; then @@ -362,7 +371,10 @@ process_install_output() { if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then local new_version - new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + new_version=$(printf '%s\n' "$output" | sed -n 's/.*(version \([^)]*\)).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + fi if [[ -z "$new_version" ]]; then new_version="$fallback_version" fi From fbff0ec3bdb66c1fd6140dbaddb555f2652ae10c Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 24 Jan 2026 08:31:04 +0800 Subject: [PATCH 63/91] fix: show spinner during cleanup phase Add progress indicator when deleting files to prevent the terminal from appearing frozen during long-running cleanup operations. Closes #358 --- bin/clean.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bin/clean.sh b/bin/clean.sh index 90e3932..93262a3 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -522,6 +522,10 @@ safe_clean() { fi # Read results back in original order. + # Start spinner for cleaning phase + if [[ "$DRY_RUN" != "true" && ${#existing_paths[@]} -gt 0 && -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Cleaning..." + fi idx=0 if [[ ${#existing_paths[@]} -gt 0 ]]; then for path in "${existing_paths[@]}"; do @@ -558,6 +562,10 @@ safe_clean() { fi else + # Start spinner for cleaning phase (small batch) + if [[ "$DRY_RUN" != "true" && ${#existing_paths[@]} -gt 0 && -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Cleaning..." + fi local idx=0 if [[ ${#existing_paths[@]} -gt 0 ]]; then for path in "${existing_paths[@]}"; do From 670f970b4c403ae0b0608219f134d6a0fab722e8 Mon Sep 17 00:00:00 2001 From: Andrei Murariu <83287213+iamxorum@users.noreply.github.com> Date: Mon, 26 Jan 2026 04:07:58 +0200 Subject: [PATCH 64/91] feature: `mo update --force` flag implementation (#360) --- mole | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/mole b/mole index 6edaf1f..7d14b41 100755 --- a/mole +++ b/mole @@ -231,6 +231,7 @@ show_help() { # Update flow (Homebrew or installer). update_mole() { + local force_update="${1:-false}" local update_interrupted=false trap 'update_interrupted=true; echo ""; exit 130' INT TERM @@ -250,7 +251,7 @@ update_mole() { exit 1 fi - if [[ "$VERSION" == "$latest" ]]; then + if [[ "$VERSION" == "$latest" && "$force_update" != "true" ]]; then echo "" echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version (${VERSION})" echo "" @@ -366,9 +367,8 @@ update_mole() { if [[ ! -f "$config_dir/lib/core/common.sh" ]]; then config_dir="$HOME/.config/mole" fi - if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" --update 2>&1); then - process_install_output "$install_output" "$latest" - else + + if [[ "$force_update" == "true" ]]; then if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" 2>&1); then process_install_output "$install_output" "$latest" else @@ -378,6 +378,20 @@ update_mole() { echo "$install_output" | tail -10 >&2 # Show last 10 lines of error exit 1 fi + else + if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" --update 2>&1); then + process_install_output "$install_output" "$latest" + else + if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" 2>&1); then + process_install_output "$install_output" "$latest" + else + if [[ -t 1 ]]; then stop_inline_spinner; fi + rm -f "$tmp_installer" + log_error "Update failed" + echo "$install_output" | tail -10 >&2 # Show last 10 lines of error + exit 1 + fi + fi fi rm -f "$tmp_installer" @@ -766,7 +780,14 @@ main() { exec "$SCRIPT_DIR/bin/completion.sh" "${args[@]:1}" ;; "update") - update_mole + local force_update=false + for arg in "${args[@]:1}"; do + case "$arg" in + --force | -f) force_update=true ;; + *) ;; + esac + done + update_mole "$force_update" exit 0 ;; "remove") From fb4cef5fa728699a0d53fa133e8bc3593f4f8dc3 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 26 Jan 2026 10:10:46 +0800 Subject: [PATCH 65/91] docs: add help text and test for 'mo update --force' --- mole | 1 + tests/update.bats | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/mole b/mole index 7d14b41..f8ae12c 100755 --- a/mole +++ b/mole @@ -223,6 +223,7 @@ show_help() { printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --dry-run" "$NC" "Preview optimization" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items" printf " %s%-28s%s %s\n" "$GREEN" "mo purge --paths" "$NC" "Configure scan directories" + printf " %s%-28s%s %s\n" "$GREEN" "mo update --force" "$NC" "Force reinstall latest version" echo printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC" printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs" diff --git a/tests/update.bats b/tests/update.bats index be95475..055603a 100644 --- a/tests/update.bats +++ b/tests/update.bats @@ -392,3 +392,52 @@ EOF [[ "$output" == *"Updated to latest version (1.23.1)"* ]] [[ "$output" != *"progress: 100%"* ]] || [[ "$output" == *"Downloading (progress: 100%)"* ]] } + +@test "update_mole with --force reinstalls even when on latest version" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" CURRENT_VERSION="$CURRENT_VERSION" PATH="$HOME/fake-bin:/usr/bin:/bin" TERM="dumb" bash --noprofile --norc << 'EOF' +set -euo pipefail +curl() { + local out="" + local url="" + while [[ $# -gt 0 ]]; do + case "$1" in + -o) + out="$2" + shift 2 + ;; + http*://*) + url="$1" + shift + ;; + *) + shift + ;; + esac + done + + if [[ -n "$out" ]]; then + cat > "$out" << 'INSTALLER' +#!/usr/bin/env bash +echo "Mole installed successfully (version $CURRENT_VERSION)" +INSTALLER + return 0 + fi + + if [[ "$url" == *"api.github.com"* ]]; then + echo "{\"tag_name\":\"$CURRENT_VERSION\"}" + else + echo "VERSION=\"$CURRENT_VERSION\"" + fi +} +export -f curl + +brew() { exit 1; } +export -f brew + +"$PROJECT_ROOT/mole" update --force +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Already on latest version"* ]] + [[ "$output" == *"Downloading"* ]] || [[ "$output" == *"Installing"* ]] || [[ "$output" == *"Updated"* ]] +} From 89eb442866d6b9dccea1603d8e2e667cff9e2cc0 Mon Sep 17 00:00:00 2001 From: Andrei Murariu <83287213+iamxorum@users.noreply.github.com> Date: Mon, 26 Jan 2026 04:28:03 +0200 Subject: [PATCH 66/91] bug-fix: add automatic trash emptying to clean command (#363) - Empty trash using macOS Finder API (osascript) for proper permission handling - Respects whitelist protection (skips if Trash is whitelisted) - Shows item count when emptying trash - Fallback to direct cleanup if Finder API fails - Supports dry-run mode Issue: #362 --- lib/clean/user.sh | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index baf98ab..3293841 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -7,8 +7,24 @@ clean_user_essentials() { stop_section_spinner safe_clean ~/Library/Logs/* "User app logs" + if ! is_path_whitelisted "$HOME/.Trash"; then - safe_clean ~/.Trash/* "Trash" + local trash_count + trash_count=$(osascript -e 'tell application "Finder" to count items in trash' 2> /dev/null || echo "0") + [[ "$trash_count" =~ ^[0-9]+$ ]] || trash_count="0" + + if [[ "${DRY_RUN:-false}" == "true" ]]; then + [[ $trash_count -gt 0 ]] && echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty ($trash_count items)" || echo -e " ${GRAY}${ICON_EMPTY}${NC} Trash · already empty" + elif [[ $trash_count -gt 0 ]]; then + if osascript -e 'tell application "Finder" to empty trash' > /dev/null 2>&1; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied ($trash_count items)" + note_activity + else + safe_clean ~/.Trash/* "Trash" + fi + else + echo -e " ${GRAY}${ICON_EMPTY}${NC} Trash · already empty" + fi fi } From 4bacba7fec1e0d1a9a497b20836c2a9352bb99cd Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 26 Jan 2026 02:28:41 +0000 Subject: [PATCH 67/91] chore: auto format code --- lib/clean/user.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 3293841..93dfc90 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -7,12 +7,12 @@ clean_user_essentials() { stop_section_spinner safe_clean ~/Library/Logs/* "User app logs" - + if ! is_path_whitelisted "$HOME/.Trash"; then local trash_count trash_count=$(osascript -e 'tell application "Finder" to count items in trash' 2> /dev/null || echo "0") [[ "$trash_count" =~ ^[0-9]+$ ]] || trash_count="0" - + if [[ "${DRY_RUN:-false}" == "true" ]]; then [[ $trash_count -gt 0 ]] && echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty ($trash_count items)" || echo -e " ${GRAY}${ICON_EMPTY}${NC} Trash · already empty" elif [[ $trash_count -gt 0 ]]; then From e803c463042c888a4cf270d0e61c6d695a0e63a8 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 26 Jan 2026 10:44:36 +0800 Subject: [PATCH 68/91] style: unify DRY_RUN variable check in trash cleanup --- lib/clean/user.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 3293841..cbbfa20 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -13,7 +13,7 @@ clean_user_essentials() { trash_count=$(osascript -e 'tell application "Finder" to count items in trash' 2> /dev/null || echo "0") [[ "$trash_count" =~ ^[0-9]+$ ]] || trash_count="0" - if [[ "${DRY_RUN:-false}" == "true" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then [[ $trash_count -gt 0 ]] && echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty ($trash_count items)" || echo -e " ${GRAY}${ICON_EMPTY}${NC} Trash · already empty" elif [[ $trash_count -gt 0 ]]; then if osascript -e 'tell application "Finder" to empty trash' > /dev/null 2>&1; then From 5a29f506dc58ea4df178c0ab06782b5d9977c01f Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 26 Jan 2026 02:46:32 +0000 Subject: [PATCH 69/91] chore: auto format code --- lib/clean/user.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 922efe8..d894ad6 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -12,7 +12,7 @@ clean_user_essentials() { local trash_count trash_count=$(osascript -e 'tell application "Finder" to count items in trash' 2> /dev/null || echo "0") [[ "$trash_count" =~ ^[0-9]+$ ]] || trash_count="0" - + if [[ "$DRY_RUN" == "true" ]]; then [[ $trash_count -gt 0 ]] && echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty ($trash_count items)" || echo -e " ${GRAY}${ICON_EMPTY}${NC} Trash · already empty" elif [[ $trash_count -gt 0 ]]; then From 61723e8d863f00ff0109ba31322daffcbb03a848 Mon Sep 17 00:00:00 2001 From: TomP0 <35064639+TomP0@users.noreply.github.com> Date: Mon, 26 Jan 2026 02:47:08 +0000 Subject: [PATCH 70/91] fix: handle empty arrays in clean_dev_jetbrains_toolbox (#361) Add checks for empty product_dirs and version_dirs arrays before iterating to prevent "unbound variable" errors when running with set -u (pipefail) and the JetBrains Toolbox directory doesn't exist or contains no products. --- lib/clean/dev.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index 3904706..464d649 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -244,6 +244,13 @@ clean_dev_jetbrains_toolbox() { product_dirs+=("$product_dir") done < <(command find "$toolbox_root" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) + if [[ ${#product_dirs[@]} -eq 0 ]]; then + if [[ "$whitelist_overridden" == "true" ]]; then + WHITELIST_PATTERNS=("${original_whitelist[@]}") + fi + return 0 + fi + local product_dir for product_dir in "${product_dirs[@]}"; do while IFS= read -r -d '' channel_dir; do @@ -276,6 +283,8 @@ clean_dev_jetbrains_toolbox() { version_dirs+=("$version_dir") done < <(command find "$channel_dir" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) + [[ ${#version_dirs[@]} -eq 0 ]] && continue + local -a sorted_dirs=() while IFS= read -r line; do local dir_path="${line#* }" From b8c25e4056842bc527beb48a95832a42af2e1a4d Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 26 Jan 2026 11:09:38 +0800 Subject: [PATCH 71/91] fix: improve cleanup code and fix Edge test isolation - Refactor JetBrains Toolbox cleanup with _restore_whitelist helper - Add MOLE_EDGE_APP_PATHS env var for test isolation - Fix Edge old versions tests to avoid scanning system Edge installation --- lib/clean/dev.sh | 23 +++++++++---------- lib/clean/user.sh | 14 ++++++++---- tests/clean_browser_versions.bats | 37 ++++++++++++++----------------- 3 files changed, 38 insertions(+), 36 deletions(-) diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index 464d649..e3d7c44 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -219,10 +219,9 @@ clean_dev_jetbrains_toolbox() { [[ -d "$toolbox_root" ]] || return 0 local keep_previous="${MOLE_JETBRAINS_TOOLBOX_KEEP:-1}" - if [[ ! "$keep_previous" =~ ^[0-9]+$ ]]; then - keep_previous=1 - fi + [[ "$keep_previous" =~ ^[0-9]+$ ]] || keep_previous=1 + # Save and filter whitelist patterns for toolbox path local whitelist_overridden="false" local -a original_whitelist=() if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then @@ -230,24 +229,26 @@ clean_dev_jetbrains_toolbox() { local -a filtered_whitelist=() local pattern for pattern in "${WHITELIST_PATTERNS[@]}"; do - if [[ "$toolbox_root" == "$pattern" || "$pattern" == "$toolbox_root"* ]]; then - continue - fi + [[ "$toolbox_root" == "$pattern" || "$pattern" == "$toolbox_root"* ]] && continue filtered_whitelist+=("$pattern") done WHITELIST_PATTERNS=("${filtered_whitelist[@]+${filtered_whitelist[@]}}") whitelist_overridden="true" fi + # Helper to restore whitelist on exit + _restore_whitelist() { + [[ "$whitelist_overridden" == "true" ]] && WHITELIST_PATTERNS=("${original_whitelist[@]}") + return 0 + } + local -a product_dirs=() while IFS= read -r -d '' product_dir; do product_dirs+=("$product_dir") done < <(command find "$toolbox_root" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) if [[ ${#product_dirs[@]} -eq 0 ]]; then - if [[ "$whitelist_overridden" == "true" ]]; then - WHITELIST_PATTERNS=("${original_whitelist[@]}") - fi + _restore_whitelist return 0 fi @@ -315,9 +316,7 @@ clean_dev_jetbrains_toolbox() { done < <(command find "$product_dir" -mindepth 1 -maxdepth 1 -type d -name "ch-*" -print0 2> /dev/null) done - if [[ "$whitelist_overridden" == "true" ]]; then - WHITELIST_PATTERNS=("${original_whitelist[@]}") - fi + _restore_whitelist } # Other language tool caches. clean_dev_other_langs() { diff --git a/lib/clean/user.sh b/lib/clean/user.sh index d894ad6..ca78f66 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -110,10 +110,16 @@ clean_chrome_old_versions() { # Remove old Microsoft Edge versions while keeping Current. clean_edge_old_versions() { - local -a app_paths=( - "/Applications/Microsoft Edge.app" - "$HOME/Applications/Microsoft Edge.app" - ) + # Allow override for testing + local -a app_paths + if [[ -n "${MOLE_EDGE_APP_PATHS:-}" ]]; then + IFS=':' read -ra app_paths <<< "$MOLE_EDGE_APP_PATHS" + else + app_paths=( + "/Applications/Microsoft Edge.app" + "$HOME/Applications/Microsoft Edge.app" + ) + fi # Match the exact Edge process name to avoid false positives (e.g., Microsoft Teams) if pgrep -x "Microsoft Edge" > /dev/null 2>&1; then diff --git a/tests/clean_browser_versions.bats b/tests/clean_browser_versions.bats index 41abf61..b90350a 100644 --- a/tests/clean_browser_versions.bats +++ b/tests/clean_browser_versions.bats @@ -248,27 +248,24 @@ EOF } @test "clean_edge_old_versions removes old versions but keeps current" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' + # Create mock Edge directory structure + local EDGE_APP="$HOME/Applications/Microsoft Edge.app" + local VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" + mkdir -p "$VERSIONS_DIR"/{120.0.0.0,121.0.0.0,122.0.0.0} + ln -s "122.0.0.0" "$VERSIONS_DIR/Current" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true \ + MOLE_EDGE_APP_PATHS="$EDGE_APP" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" pgrep() { return 1; } -export -f pgrep - -# Create mock Edge directory structure -EDGE_APP="$HOME/Applications/Microsoft Edge.app" -VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" -mkdir -p "$VERSIONS_DIR"/{120.0.0.0,121.0.0.0,122.0.0.0} - -# Create Current symlink pointing to 122.0.0.0 -ln -s "122.0.0.0" "$VERSIONS_DIR/Current" - is_path_whitelisted() { return 1; } get_path_size_kb() { echo "10240"; } bytes_to_human() { echo "10M"; } note_activity() { :; } -export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity +export -f pgrep is_path_whitelisted get_path_size_kb bytes_to_human note_activity files_cleaned=0 total_size_cleaned=0 @@ -289,7 +286,14 @@ EOF # Use a fresh temp directory for this test TEST_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-test8.XXXXXX")" - run env HOME="$TEST_HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + # Create Edge with only current version + local EDGE_APP="$TEST_HOME/Applications/Microsoft Edge.app" + local VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" + mkdir -p "$VERSIONS_DIR/122.0.0.0" + ln -s "122.0.0.0" "$VERSIONS_DIR/Current" + + run env HOME="$TEST_HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_EDGE_APP_PATHS="$EDGE_APP" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -301,17 +305,10 @@ bytes_to_human() { echo "10M"; } note_activity() { :; } export -f pgrep is_path_whitelisted get_path_size_kb bytes_to_human note_activity -# Initialize counters files_cleaned=0 total_size_cleaned=0 total_items=0 -# Create Edge with only current version -EDGE_APP="$HOME/Applications/Microsoft Edge.app" -VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" -mkdir -p "$VERSIONS_DIR/122.0.0.0" -ln -s "122.0.0.0" "$VERSIONS_DIR/Current" - clean_edge_old_versions EOF From a7bad3d79aa7db0bc2b10fc318542285f4559d3c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 03:12:00 +0000 Subject: [PATCH 72/91] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 275 ++++++++++++++++++++++++----------------------- 1 file changed, 143 insertions(+), 132 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 26f9098..f74fcb4 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -46,6 +46,17 @@ + + + + + + + + iamxorum + + + @@ -56,7 +67,7 @@ amanthanvi - + @@ -67,17 +78,6 @@ dwjoss - - - - - - - - - iamxorum - - @@ -157,115 +157,16 @@ - + - - - ndbroadbent + + + NanmiCoder - - - - - - - - shakeelmohamed - - - - - - - - - - - Sizk - - - - - - - - - - - thijsvanhal - - - - - - - - - - - yuzeguitarist - - - - - - - - - - - zeldrisho - - - - - - - - - - - bikraj2 - - - - - - - - - - - bunizao - - - - - - - - - - - frozturk - - - - - - - - - - - huyixi - - - @@ -276,18 +177,128 @@ purofle - + - + - - - NanmiCoder + + + huyixi + + + + + + + + + + + frozturk + + + + + + + + + + + bunizao + + + + + + + + + + + bikraj2 + + + + + + + + + + + zeldrisho + + + + + + + + + + + yuzeguitarist + + + + + + + + + + + TomP0 + + + + + + + + + + + thijsvanhal + + + + + + + + + + + Sizk + + + + + + + + + + + shakeelmohamed + + + + + + + + ndbroadbent + + + @@ -298,7 +309,7 @@ MohammedEsafi - + @@ -309,7 +320,7 @@ Schlauer-Hax - + @@ -320,7 +331,7 @@ anonymort - + @@ -331,7 +342,7 @@ khipu-luke - + @@ -342,7 +353,7 @@ LmanTW - + @@ -353,7 +364,7 @@ kwakubiney - + @@ -364,7 +375,7 @@ kowyo - + @@ -375,7 +386,7 @@ jalen0x - + @@ -386,7 +397,7 @@ Hensell - + @@ -397,7 +408,7 @@ Copper-Eye - + @@ -408,7 +419,7 @@ ClathW - + From e966838c8278bd244ac2f7b63ca02837a5a9bee8 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 26 Jan 2026 14:36:06 +0800 Subject: [PATCH 73/91] style: standardize punctuation across codebase - Replace parentheses with commas for supplementary info - Use commas instead of em-dashes for separators - Update bullet points from - to * in some contexts - Improve version extraction regex with fallback logic --- README.md | 6 ++--- bin/clean.sh | 22 ++++++++-------- bin/completion.sh | 2 +- bin/installer.sh | 8 +++--- bin/optimize.sh | 22 ++++++++-------- bin/purge.sh | 4 +-- bin/touchid.sh | 6 ++--- bin/uninstall.sh | 2 +- bin/uninstall_lib.sh | 2 +- cmd/analyze/main.go | 18 ++++++------- cmd/analyze/scanner.go | 2 +- cmd/analyze/view.go | 14 +++++----- cmd/status/view.go | 12 ++++----- install.sh | 12 ++++----- lib/clean/apps.sh | 12 ++++----- lib/clean/brew.sh | 6 ++--- lib/clean/caches.sh | 8 +++--- lib/clean/project.sh | 4 +-- lib/clean/system.sh | 20 +++++++-------- lib/clean/user.sh | 34 ++++++++++++------------ lib/core/base.sh | 18 ++++++------- lib/core/common.sh | 4 +-- lib/core/file_ops.sh | 20 +++++++-------- lib/core/log.sh | 15 +++++------ lib/core/sudo.sh | 4 +-- lib/core/timeout.sh | 2 +- lib/manage/purge_paths.sh | 4 +-- lib/manage/update.sh | 14 +++++----- lib/manage/whitelist.sh | 8 +++--- lib/optimize/tasks.sh | 6 ++--- lib/uninstall/batch.sh | 8 +++--- mole | 27 +++++++++++--------- scripts/check.sh | 18 ++++++------- scripts/setup-quick-launchers.sh | 12 ++++----- scripts/test.sh | 4 +-- tests/update.bats | 44 ++++++++++++++++++++------------ 36 files changed, 219 insertions(+), 205 deletions(-) diff --git a/README.md b/README.md index 1709e4d..9b7f37e 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ brew install mole curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash ``` -**Windows:** Mole is designed for macOS, but we offer an experimental Windows version based on user demand. See the [windows branch](https://github.com/tw93/Mole/tree/windows) — for early adopters only. +**Windows:** Mole is designed for macOS, but we offer an experimental Windows version based on user demand. See the [windows branch](https://github.com/tw93/Mole/tree/windows), for early adopters only. **Run:** @@ -210,7 +210,7 @@ Select Categories to Clean - 18.5GB (8 selected) ● backend-service 2.5GB | node_modules ``` -> **Use with caution:** This will permanently delete selected artifacts. Review carefully before confirming. Recent projects — less than 7 days old — are marked and unselected by default. +> **Use with caution:** This will permanently delete selected artifacts. Review carefully before confirming. Recent projects, less than 7 days old, are marked and unselected by default.
Custom Scan Paths @@ -282,4 +282,4 @@ Join thousands of users worldwide who trust Mole to keep their Macs clean and op ## License -MIT License — feel free to enjoy and participate in open source. +MIT License, feel free to enjoy and participate in open source. diff --git a/bin/clean.sh b/bin/clean.sh index 93262a3..4320106 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -381,7 +381,7 @@ safe_clean() { stop_section_spinner fi - debug_log "Cleaning: $description (${#existing_paths[@]} items)" + debug_log "Cleaning: $description, ${#existing_paths[@]} items" # Enhanced debug output with risk level and details if [[ "${MO_DEBUG:-}" == "1" && ${#existing_paths[@]} -gt 0 ]]; then @@ -612,7 +612,7 @@ safe_clean() { debug_log "Permission denied while cleaning: $description" fi if [[ $removal_failed_count -gt 0 && "$DRY_RUN" != "true" ]]; then - debug_log "Skipped $removal_failed_count items (permission denied or in use) for: $description" + debug_log "Skipped $removal_failed_count items, permission denied or in use, for: $description" fi if [[ $removed_any -eq 1 ]]; then @@ -627,7 +627,7 @@ safe_clean() { fi if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label ${YELLOW}($size_human dry)${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label${NC}, ${YELLOW}$size_human dry${NC}" local paths_temp=$(create_temp_file) @@ -678,7 +678,7 @@ safe_clean() { ' | while IFS='|' read -r display_path total_size child_count; do local size_human=$(bytes_to_human "$((total_size * 1024))") if [[ $child_count -gt 1 ]]; then - echo "$display_path # $size_human ($child_count items)" >> "$EXPORT_LIST_FILE" + echo "$display_path # $size_human, $child_count items" >> "$EXPORT_LIST_FILE" else echo "$display_path # $size_human" >> "$EXPORT_LIST_FILE" fi @@ -687,7 +687,7 @@ safe_clean() { rm -f "$paths_temp" fi else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label ${GREEN}($size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$size_human${NC}" fi ((files_cleaned += total_count)) ((total_size_cleaned += total_size_kb)) @@ -711,7 +711,7 @@ start_cleanup() { fi if [[ "$DRY_RUN" == "true" ]]; then - echo -e "${YELLOW}Dry Run Mode${NC} - Preview only, no deletions" + echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions" echo "" SYSTEM_CLEAN=false @@ -737,7 +737,7 @@ EOF echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access already available" echo "" else - echo -ne "${PURPLE}${ICON_ARROW}${NC} System caches need sudo — ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: " + echo -ne "${PURPLE}${ICON_ARROW}${NC} System caches need sudo. ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: " local choice choice=$(read_key) @@ -774,10 +774,10 @@ EOF echo "Running in non-interactive mode" if sudo -n true 2> /dev/null; then SYSTEM_CLEAN=true - echo " ${ICON_LIST} System-level cleanup enabled (sudo session active)" + echo " ${ICON_LIST} System-level cleanup enabled, sudo session active" else SYSTEM_CLEAN=false - echo " ${ICON_LIST} System-level cleanup skipped (requires sudo)" + echo " ${ICON_LIST} System-level cleanup skipped, requires sudo" fi echo " ${ICON_LIST} User-level cleanup will proceed automatically" echo "" @@ -790,7 +790,7 @@ perform_cleanup() { if [[ "${MOLE_TEST_MODE:-0}" == "1" ]]; then test_mode_enabled=true if [[ "$DRY_RUN" == "true" ]]; then - echo -e "${YELLOW}Dry Run Mode${NC} - Preview only, no deletions" + echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions" echo "" fi echo -e "${GREEN}${ICON_LIST}${NC} User app cache" @@ -1054,7 +1054,7 @@ perform_cleanup() { else summary_status="info" if [[ "$DRY_RUN" == "true" ]]; then - summary_details+=("No significant reclaimable space detected (system already clean).") + summary_details+=("No significant reclaimable space detected, system already clean.") else summary_details+=("System was already clean; no additional space freed.") fi diff --git a/bin/completion.sh b/bin/completion.sh index b3d345c..0a187e3 100755 --- a/bin/completion.sh +++ b/bin/completion.sh @@ -84,7 +84,7 @@ if [[ $# -eq 0 ]]; then echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed stale completion entries from $config_file" echo "" fi - log_error "mole not found in PATH - install Mole before enabling completion" + log_error "mole not found in PATH, install Mole before enabling completion" exit 1 fi diff --git a/bin/installer.sh b/bin/installer.sh index 8eed412..f088c19 100755 --- a/bin/installer.sh +++ b/bin/installer.sh @@ -387,7 +387,7 @@ select_installers() { scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}" fi - printf "${PURPLE_BOLD}Select Installers to Remove${NC}%s ${GRAY}- ${selected_human} ($selected_count selected)${NC}\n" "$scroll_indicator" + printf "${PURPLE_BOLD}Select Installers to Remove${NC}%s ${GRAY}, ${selected_human}, ${selected_count} selected${NC}\n" "$scroll_indicator" printf "%s\n" "$clear_line" # Calculate visible range @@ -546,13 +546,13 @@ delete_selected_installers() { local file_size="${INSTALLER_SIZES[$idx]}" local size_human size_human=$(bytes_to_human "$file_size") - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $(basename "$file_path") ${GRAY}(${size_human})${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $(basename "$file_path") ${GRAY}, ${size_human}${NC}" fi done # Confirm deletion echo "" - echo -ne "${PURPLE}${ICON_ARROW}${NC} Delete ${#selected_indices[@]} installer(s) (${confirm_human}) ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: " + echo -ne "${PURPLE}${ICON_ARROW}${NC} Delete ${#selected_indices[@]} installers, ${confirm_human} ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: " IFS= read -r -s -n1 confirm || confirm="" case "$confirm" in @@ -655,7 +655,7 @@ show_summary() { local freed_mb freed_mb=$(echo "$total_size_freed_kb" | awk '{printf "%.2f", $1/1024}') - summary_details+=("Removed ${GREEN}$total_deleted${NC} installer(s), freed ${GREEN}${freed_mb}MB${NC}") + summary_details+=("Removed ${GREEN}$total_deleted${NC} installers, freed ${GREEN}${freed_mb}MB${NC}") summary_details+=("Your Mac is cleaner now!") else summary_details+=("No installers were removed") diff --git a/bin/optimize.sh b/bin/optimize.sh index 0413b5a..9fe3864 100755 --- a/bin/optimize.sh +++ b/bin/optimize.sh @@ -78,7 +78,7 @@ show_optimization_summary() { local total_applied=$((safe_count + confirm_count)) if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - summary_title="Dry Run Complete - No Changes Made" + summary_title="Dry Run Complete, No Changes Made" summary_details+=("Would apply ${YELLOW}${total_applied:-0}${NC} optimizations") summary_details+=("Run without ${YELLOW}--dry-run${NC} to apply these changes") else @@ -115,9 +115,9 @@ show_optimization_summary() { fi if [[ -n "$key_stat" ]]; then - summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations — ${key_stat}") + summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations, ${key_stat}") else - summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations — all services tuned") + summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations, all services tuned") fi local summary_line3="" @@ -126,11 +126,11 @@ show_optimization_summary() { if [[ -n "${AUTO_FIX_DETAILS:-}" ]]; then local detail_join detail_join=$(echo "${AUTO_FIX_DETAILS}" | paste -sd ", " -) - [[ -n "$detail_join" ]] && summary_line3+=" — ${detail_join}" + [[ -n "$detail_join" ]] && summary_line3+=": ${detail_join}" fi summary_details+=("$summary_line3") fi - summary_details+=("System fully optimized — faster, more secure and responsive") + summary_details+=("System fully optimized") fi print_summary_block "$summary_title" "${summary_details[@]}" @@ -226,12 +226,12 @@ cleanup_path() { if [[ "$removed" == "true" ]]; then if [[ -n "$size_display" ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} $label ${GREEN}(${size_display})${NC}" + echo -e "${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}${size_display}${NC}" else echo -e "${GREEN}${ICON_SUCCESS}${NC} $label" fi else - echo -e "${GRAY}${ICON_WARNING}${NC} Skipped $label ${GRAY}(grant Full Disk Access to your terminal and retry)${NC}" + echo -e "${GRAY}${ICON_WARNING}${NC} Skipped $label${GRAY}, grant Full Disk Access to your terminal and retry${NC}" fi } @@ -252,7 +252,7 @@ collect_security_fix_actions() { fi if [[ "${GATEKEEPER_DISABLED:-}" == "true" ]]; then if ! is_whitelisted "gatekeeper"; then - SECURITY_FIXES+=("gatekeeper|Enable Gatekeeper (App download protection)") + SECURITY_FIXES+=("gatekeeper|Enable Gatekeeper, app download protection") fi fi if touchid_supported && ! touchid_configured; then @@ -304,7 +304,7 @@ apply_firewall_fix() { FIREWALL_DISABLED=false return 0 fi - echo -e " ${GRAY}${ICON_WARNING}${NC} Failed to enable firewall (check permissions)" + echo -e " ${GRAY}${ICON_WARNING}${NC} Failed to enable firewall, check permissions" return 1 } @@ -327,7 +327,7 @@ apply_touchid_fix() { perform_security_fixes() { if ! ensure_sudo_session "Security changes require admin access"; then - echo -e "${GRAY}${ICON_WARNING}${NC} Skipped security fixes (sudo denied)" + echo -e "${GRAY}${ICON_WARNING}${NC} Skipped security fixes, sudo denied" return 1 fi @@ -391,7 +391,7 @@ main() { # Dry-run indicator. if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC} - No files will be modified\n" + echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No files will be modified\n" fi if ! command -v jq > /dev/null 2>&1; then diff --git a/bin/purge.sh b/bin/purge.sh index b169a77..eb3af0b 100755 --- a/bin/purge.sh +++ b/bin/purge.sh @@ -220,7 +220,7 @@ perform_purge() { # Show help message show_help() { - echo -e "${PURPLE_BOLD}Mole Purge${NC} - Clean old project build artifacts" + echo -e "${PURPLE_BOLD}Mole Purge${NC}, Clean old project build artifacts" echo "" echo -e "${YELLOW}Usage:${NC} mo purge [options]" echo "" @@ -231,7 +231,7 @@ show_help() { echo "" echo -e "${YELLOW}Default Paths:${NC}" for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do - echo " - $path" + echo " * $path" done } diff --git a/bin/touchid.sh b/bin/touchid.sh index 4dce339..9f84dd0 100755 --- a/bin/touchid.sh +++ b/bin/touchid.sh @@ -141,7 +141,7 @@ enable_touchid() { sudo mv "$temp_file" "$PAM_SUDO_FILE" log_success "Touch ID migrated to sudo_local" else - log_success "Touch ID enabled (via sudo_local) - try: sudo ls" + log_success "Touch ID enabled, via sudo_local, try: sudo ls" fi return 0 else @@ -188,7 +188,7 @@ enable_touchid() { # Apply the changes if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then - log_success "Touch ID enabled - try: sudo ls" + log_success "Touch ID enabled, try: sudo ls" return 0 else log_error "Failed to enable Touch ID" @@ -219,7 +219,7 @@ disable_touchid() { grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" sudo mv "$temp_file" "$PAM_SUDO_FILE" fi - echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled (removed from sudo_local)${NC}" + echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled, removed from sudo_local${NC}" echo "" return 0 else diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 94cbf66..13b20cc 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -496,7 +496,7 @@ main() { rm -f "$apps_file" continue fi - echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} app(s):" + echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} apps:" local -a summary_rows=() local max_name_display_width=0 local max_size_width=0 diff --git a/bin/uninstall_lib.sh b/bin/uninstall_lib.sh index 75e0ad0..26a94cc 100755 --- a/bin/uninstall_lib.sh +++ b/bin/uninstall_lib.sh @@ -565,7 +565,7 @@ main() { continue fi # Show selected apps with clean alignment - echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} app(s):" + echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} apps:" local -a summary_rows=() local max_name_width=0 local max_size_width=0 diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index 0978187..e06bc41 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -332,9 +332,9 @@ func (m *model) scheduleOverviewScans() tea.Cmd { if len(pendingIndices) > 0 { firstEntry := m.entries[pendingIndices[0]] if len(pendingIndices) == 1 { - m.status = fmt.Sprintf("Scanning %s... (%d left)", firstEntry.Name, remaining) + m.status = fmt.Sprintf("Scanning %s..., %d left", firstEntry.Name, remaining) } else { - m.status = fmt.Sprintf("Scanning %d directories... (%d left)", len(pendingIndices), remaining) + m.status = fmt.Sprintf("Scanning %d directories..., %d left", len(pendingIndices), remaining) } } @@ -736,7 +736,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(m.largeMultiSelected) > 0 { count := len(m.largeMultiSelected) if count > maxBatchOpen { - m.status = fmt.Sprintf("Too many items to open (max %d, selected %d)", maxBatchOpen, count) + m.status = fmt.Sprintf("Too many items to open, max %d, selected %d", maxBatchOpen, count) return m, nil } for path := range m.largeMultiSelected { @@ -761,7 +761,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(m.multiSelected) > 0 { count := len(m.multiSelected) if count > maxBatchOpen { - m.status = fmt.Sprintf("Too many items to open (max %d, selected %d)", maxBatchOpen, count) + m.status = fmt.Sprintf("Too many items to open, max %d, selected %d", maxBatchOpen, count) return m, nil } for path := range m.multiSelected { @@ -790,7 +790,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(m.largeMultiSelected) > 0 { count := len(m.largeMultiSelected) if count > maxBatchReveal { - m.status = fmt.Sprintf("Too many items to reveal (max %d, selected %d)", maxBatchReveal, count) + m.status = fmt.Sprintf("Too many items to reveal, max %d, selected %d", maxBatchReveal, count) return m, nil } for path := range m.largeMultiSelected { @@ -815,7 +815,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(m.multiSelected) > 0 { count := len(m.multiSelected) if count > maxBatchReveal { - m.status = fmt.Sprintf("Too many items to reveal (max %d, selected %d)", maxBatchReveal, count) + m.status = fmt.Sprintf("Too many items to reveal, max %d, selected %d", maxBatchReveal, count) return m, nil } for path := range m.multiSelected { @@ -860,7 +860,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } } - m.status = fmt.Sprintf("%d selected (%s)", count, humanizeBytes(totalSize)) + m.status = fmt.Sprintf("%d selected, %s", count, humanizeBytes(totalSize)) } else { m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) } @@ -886,7 +886,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } } - m.status = fmt.Sprintf("%d selected (%s)", count, humanizeBytes(totalSize)) + m.status = fmt.Sprintf("%d selected, %s", count, humanizeBytes(totalSize)) } else { m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) } @@ -1011,7 +1011,7 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { } return m, tea.Batch(m.scanCmd(m.path), tickCmd()) } - m.status = fmt.Sprintf("File: %s (%s)", selected.Name, humanizeBytes(selected.Size)) + m.status = fmt.Sprintf("File: %s, %s", selected.Name, humanizeBytes(selected.Size)) return m, nil } diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index efd55b6..5a0cde3 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -594,7 +594,7 @@ func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, return 0, fmt.Errorf("du timeout after %v", duTimeout) } if stderr.Len() > 0 { - return 0, fmt.Errorf("du failed: %v (%s)", err, stderr.String()) + return 0, fmt.Errorf("du failed: %v, %s", err, stderr.String()) } return 0, fmt.Errorf("du failed: %v", err) } diff --git a/cmd/analyze/view.go b/cmd/analyze/view.go index 726f1a9..5f82820 100644 --- a/cmd/analyze/view.go +++ b/cmd/analyze/view.go @@ -86,7 +86,7 @@ func (m model) View() string { if m.scanning && percent >= 100 { percent = 99 } - progressPrefix = fmt.Sprintf(" %s(%.0f%%)%s", colorCyan, percent, colorReset) + progressPrefix = fmt.Sprintf(" %s%.0f%%%s", colorCyan, percent, colorReset) } fmt.Fprintf(&b, "%s%s%s%s Scanning%s: %s%s files%s, %s%s dirs%s, %s%s%s\n", @@ -342,7 +342,7 @@ func (m model) View() string { } else if m.showLargeFiles { selectCount := len(m.largeMultiSelected) if selectCount > 0 { - fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | F File | ⌫ Del(%d) | ← Back | Q Quit%s\n", colorGray, selectCount, colorReset) + fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | F File | ⌫ Del %d | ← Back | Q Quit%s\n", colorGray, selectCount, colorReset) } else { fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | F File | ⌫ Del | ← Back | Q Quit%s\n", colorGray, colorReset) } @@ -351,13 +351,13 @@ func (m model) View() string { selectCount := len(m.multiSelected) if selectCount > 0 { if largeFileCount > 0 { - fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del(%d) | T Top(%d) | Q Quit%s\n", colorGray, selectCount, largeFileCount, colorReset) + fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del %d | T Top %d | Q Quit%s\n", colorGray, selectCount, largeFileCount, colorReset) } else { - fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del(%d) | Q Quit%s\n", colorGray, selectCount, colorReset) + fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del %d | Q Quit%s\n", colorGray, selectCount, colorReset) } } else { if largeFileCount > 0 { - fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del | T Top(%d) | Q Quit%s\n", colorGray, largeFileCount, colorReset) + fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del | T Top %d | Q Quit%s\n", colorGray, largeFileCount, colorReset) } else { fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del | Q Quit%s\n", colorGray, colorReset) } @@ -390,12 +390,12 @@ func (m model) View() string { } if deleteCount > 1 { - fmt.Fprintf(&b, "%sDelete:%s %d items (%s) %sPress Enter to confirm | ESC cancel%s\n", + fmt.Fprintf(&b, "%sDelete:%s %d items, %s %sPress Enter to confirm | ESC cancel%s\n", colorRed, colorReset, deleteCount, humanizeBytes(totalDeleteSize), colorGray, colorReset) } else { - fmt.Fprintf(&b, "%sDelete:%s %s (%s) %sPress Enter to confirm | ESC cancel%s\n", + fmt.Fprintf(&b, "%sDelete:%s %s, %s %sPress Enter to confirm | ESC cancel%s\n", colorRed, colorReset, m.deleteTarget.Name, humanizeBytes(m.deleteTarget.Size), colorGray, colorReset) diff --git a/cmd/status/view.go b/cmd/status/view.go index 010e11a..b66befd 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -145,7 +145,7 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int cpuInfo := m.Hardware.CPUModel // Append GPU core count when available. if len(m.GPU) > 0 && m.GPU[0].CoreCount > 0 { - cpuInfo += fmt.Sprintf(" (%dGPU)", m.GPU[0].CoreCount) + cpuInfo += fmt.Sprintf(", %dGPU", m.GPU[0].CoreCount) } infoParts = append(infoParts, cpuInfo) } @@ -218,7 +218,7 @@ func renderCPUCard(cpu CPUStatus, thermal ThermalStatus) cardData { lines = append(lines, fmt.Sprintf("Total %s %s", usageBar, headerText)) if cpu.PerCoreEstimated { - lines = append(lines, subtleStyle.Render("Per-core data unavailable (using averaged load)")) + lines = append(lines, subtleStyle.Render("Per-core data unavailable, using averaged load")) } else if len(cpu.PerCore) > 0 { type coreUsage struct { idx int @@ -239,10 +239,10 @@ func renderCPUCard(cpu CPUStatus, thermal ThermalStatus) cardData { // Load line at the end if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 { - lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f (%dP+%dE)", + lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f, %dP+%dE", cpu.Load1, cpu.Load5, cpu.Load15, cpu.PCoreCount, cpu.ECoreCount)) } else { - lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f (%d cores)", + lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f, %d cores", cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU)) } @@ -270,7 +270,7 @@ func renderMemoryCard(mem MemoryStatus) cardData { if mem.SwapTotal > 0 { swapPercent = (float64(mem.SwapUsed) / float64(mem.SwapTotal)) * 100.0 } - swapText := fmt.Sprintf("(%s/%s)", humanBytesCompact(mem.SwapUsed), humanBytesCompact(mem.SwapTotal)) + swapText := fmt.Sprintf("%s/%s", humanBytesCompact(mem.SwapUsed), humanBytesCompact(mem.SwapTotal)) lines = append(lines, fmt.Sprintf("Swap %s %5.1f%% %s", progressBar(swapPercent), swapPercent, swapText)) lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total))) @@ -361,7 +361,7 @@ func formatDiskLine(label string, d DiskStatus) string { bar := progressBar(d.UsedPercent) used := humanBytesShort(d.Used) total := humanBytesShort(d.Total) - return fmt.Sprintf("%-6s %s %5.1f%% (%s/%s)", label, bar, d.UsedPercent, used, total) + return fmt.Sprintf("%-6s %s %5.1f%%, %s/%s", label, bar, d.UsedPercent, used, total) } func ioBar(rate float64) string { diff --git a/install.sh b/install.sh index b7d05be..6dc2cef 100755 --- a/install.sh +++ b/install.sh @@ -165,7 +165,7 @@ resolve_source_dir() { url="https://github.com/tw93/mole/archive/refs/tags/${branch}.tar.gz" fi - start_line_spinner "Fetching Mole source (${branch})..." + start_line_spinner "Fetching Mole source, ${branch}..." if command -v curl > /dev/null 2>&1; then if curl -fsSL --connect-timeout 10 --max-time 60 -o "$tmp/mole.tar.gz" "$url" 2> /dev/null; then if tar -xzf "$tmp/mole.tar.gz" -C "$tmp" 2> /dev/null; then @@ -509,7 +509,7 @@ download_binary() { log_success "Downloaded ${binary_name} binary" else if [[ -t 1 ]]; then stop_line_spinner; fi - log_warning "Could not download ${binary_name} binary (v${version}), trying local build" + log_warning "Could not download ${binary_name} binary, v${version}, trying local build" if build_binary_from_source "$binary_name" "$target_path"; then return 0 fi @@ -659,9 +659,9 @@ print_usage_summary() { local message="Mole ${action} successfully" if [[ "$action" == "updated" && -n "$previous_version" && -n "$new_version" && "$previous_version" != "$new_version" ]]; then - message+=" (${previous_version} -> ${new_version})" + message+=", ${previous_version} -> ${new_version}" elif [[ -n "$new_version" ]]; then - message+=" (version ${new_version})" + message+=", version ${new_version}" fi log_confirm "$message" @@ -763,7 +763,7 @@ perform_update() { fi if [[ "$installed_version" == "$target_version" ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version ($installed_version)" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version, $installed_version" exit 0 fi @@ -794,7 +794,7 @@ perform_update() { updated_version="$target_version" fi - echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version ($updated_version)" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, $updated_version" } parse_args "$@" diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index 3511b78..f3e084b 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -46,9 +46,9 @@ clean_ds_store_tree() { local size_human size_human=$(bytes_to_human "$total_bytes") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label ${YELLOW}($file_count files, $size_human dry)${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label${NC}, ${YELLOW}$file_count files, $size_human dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label ${GREEN}($file_count files, $size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$file_count files, $size_human${NC}" fi local size_kb=$(((total_bytes + 1023) / 1024)) ((files_cleaned += file_count)) @@ -70,7 +70,7 @@ scan_installed_apps() { current_time=$(get_epoch_seconds) local age=$((current_time - cache_mtime)) if [[ $age -lt $cache_age_seconds ]]; then - debug_log "Using cached app list (age: ${age}s)" + debug_log "Using cached app list, age: ${age}s" if [[ -r "$cache_file" ]] && [[ -s "$cache_file" ]]; then if cat "$cache_file" > "$installed_bundles" 2> /dev/null; then return 0 @@ -82,7 +82,7 @@ scan_installed_apps() { fi fi fi - debug_log "Scanning installed applications (cache expired or missing)" + debug_log "Scanning installed applications, cache expired or missing" local -a app_dirs=( "/Applications" "/System/Applications" @@ -310,7 +310,7 @@ clean_orphaned_app_data() { stop_section_spinner if [[ $orphaned_count -gt 0 ]]; then local orphaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}') - echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count items (~${orphaned_mb}MB)" + echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count items, about ${orphaned_mb}MB" note_activity fi rm -f "$installed_bundles" @@ -512,7 +512,7 @@ clean_orphaned_system_services() { else orphaned_kb_display="${total_orphaned_kb}KB" fi - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count orphaned services (~$orphaned_kb_display)" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count orphaned services, about $orphaned_kb_display" note_activity fi diff --git a/lib/clean/brew.sh b/lib/clean/brew.sh index 9939cb0..6ccf2d6 100644 --- a/lib/clean/brew.sh +++ b/lib/clean/brew.sh @@ -86,9 +86,9 @@ clean_homebrew() { freed_space=$(printf '%s\n' "$brew_output" | grep -o "[0-9.]*[KMGT]B freed" 2> /dev/null | tail -1 || true) if [[ $removed_count -gt 0 ]] || [[ -n "$freed_space" ]]; then if [[ -n "$freed_space" ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup ${GREEN}($freed_space)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup${NC}, ${GREEN}$freed_space${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup (${removed_count} items)" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup, ${removed_count} items" fi fi elif [[ $brew_exit -eq 124 ]]; then @@ -102,7 +102,7 @@ clean_homebrew() { local removed_packages removed_packages=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" 2> /dev/null || true) if [[ $removed_packages -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed orphaned dependencies (${removed_packages} packages)" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed orphaned dependencies, ${removed_packages} packages" fi elif [[ $autoremove_exit -eq 124 ]]; then echo -e " ${GRAY}${ICON_WARNING}${NC} Autoremove timed out · run ${GRAY}brew autoremove${NC} manually" diff --git a/lib/clean/caches.sh b/lib/clean/caches.sh index 2fdfe87..b5ecdcf 100644 --- a/lib/clean/caches.sh +++ b/lib/clean/caches.sh @@ -22,7 +22,7 @@ check_tcc_permissions() { echo "" echo -e "${BLUE}First-time setup${NC}" echo -e "${GRAY}macOS will request permissions to access Library folders.${NC}" - echo -e "${GRAY}You may see ${GREEN}${#tcc_dirs[@]} permission dialogs${NC}${GRAY} - please approve them all.${NC}" + echo -e "${GRAY}You may see ${GREEN}${#tcc_dirs[@]} permission dialogs${NC}${GRAY}, please approve them all.${NC}" echo "" echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to continue: " read -r @@ -75,12 +75,12 @@ clean_service_worker_cache() { local cleaned_mb=$((cleaned_size / 1024)) if [[ "$DRY_RUN" != "true" ]]; then if [[ $protected_count -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker (${cleaned_mb}MB, ${protected_count} protected)" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker, ${cleaned_mb}MB, ${protected_count} protected" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker (${cleaned_mb}MB)" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker, ${cleaned_mb}MB" fi else - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $browser_name Service Worker (would clean ${cleaned_mb}MB, ${protected_count} protected)" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $browser_name Service Worker, would clean ${cleaned_mb}MB, ${protected_count} protected" fi note_activity if [[ "$spinner_was_running" == "true" ]]; then diff --git a/lib/clean/project.sh b/lib/clean/project.sh index a34e49c..e1ffa25 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -606,7 +606,7 @@ select_purge_categories() { fi printf "%s\n" "$clear_line" - printf "%s${PURPLE_BOLD}Select Categories to Clean${NC}%s ${GRAY}- ${selected_gb}GB ($selected_count selected)${NC}\n" "$clear_line" "$scroll_indicator" + printf "%s${PURPLE_BOLD}Select Categories to Clean${NC}%s ${GRAY}, ${selected_gb}GB, ${selected_count} selected${NC}\n" "$clear_line" "$scroll_indicator" printf "%s\n" "$clear_line" IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}" @@ -1135,7 +1135,7 @@ clean_project_artifacts() { fi if [[ -t 1 ]]; then stop_inline_spinner - echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path - $artifact_type ${GREEN}($size_human)${NC}" + echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}" fi done # Update count diff --git a/lib/clean/system.sh b/lib/clean/system.sh index f06dd0c..e3d282e 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -39,18 +39,18 @@ clean_deep_system() { if [[ -d "/macOS Install Data" ]]; then local mtime=$(get_file_mtime "/macOS Install Data") local age_days=$((($(get_epoch_seconds) - mtime) / 86400)) - debug_log "Found macOS Install Data (age: ${age_days} days)" + debug_log "Found macOS Install Data, age ${age_days} days" if [[ $age_days -ge 30 ]]; then local size_kb=$(get_path_size_kb "/macOS Install Data") if [[ -n "$size_kb" && "$size_kb" -gt 0 ]]; then local size_human=$(bytes_to_human "$((size_kb * 1024))") - debug_log "Cleaning macOS Install Data: $size_human (${age_days} days old)" + debug_log "Cleaning macOS Install Data: $size_human, ${age_days} days old" if safe_sudo_remove "/macOS Install Data"; then - log_success "macOS Install Data ($size_human)" + log_success "macOS Install Data, $size_human" fi fi else - debug_log "Keeping macOS Install Data (only ${age_days} days old, needs 30+)" + debug_log "Keeping macOS Install Data, only ${age_days} days old, needs 30+" fi fi start_section_spinner "Scanning system caches..." @@ -70,13 +70,13 @@ clean_deep_system() { local current_time current_time=$(get_epoch_seconds) if [[ $((current_time - last_update_time)) -ge $update_interval ]]; then - start_section_spinner "Scanning system caches... ($found_count found)" + start_section_spinner "Scanning system caches... $found_count found" last_update_time=$current_time fi fi done < <(run_with_timeout 5 command find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true) stop_section_spinner - [[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches ($code_sign_cleaned items)" + [[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches, $code_sign_cleaned items" start_section_spinner "Cleaning system diagnostic logs..." local diag_cleaned=0 @@ -178,7 +178,7 @@ clean_time_machine_failed_backups() { local backup_name=$(basename "$inprogress_file") local size_human=$(bytes_to_human "$((size_kb * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete backup: $backup_name ${YELLOW}($size_human dry)${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete backup: $backup_name${NC}, ${YELLOW}$size_human dry${NC}" ((tm_cleaned++)) note_activity continue @@ -188,7 +188,7 @@ clean_time_machine_failed_backups() { continue fi if tmutil delete "$inprogress_file" 2> /dev/null; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name ${GREEN}($size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name${NC}, ${GREEN}$size_human${NC}" ((tm_cleaned++)) ((files_cleaned++)) ((total_size_cleaned += size_kb)) @@ -224,7 +224,7 @@ clean_time_machine_failed_backups() { local backup_name=$(basename "$inprogress_file") local size_human=$(bytes_to_human "$((size_kb * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete APFS backup in $bundle_name: $backup_name ${YELLOW}($size_human dry)${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, ${YELLOW}$size_human dry${NC}" ((tm_cleaned++)) note_activity continue @@ -233,7 +233,7 @@ clean_time_machine_failed_backups() { continue fi if tmutil delete "$inprogress_file" 2> /dev/null; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name ${GREEN}($size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, ${GREEN}$size_human${NC}" ((tm_cleaned++)) ((files_cleaned++)) ((total_size_cleaned += size_kb)) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index ca78f66..591bf19 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -14,10 +14,10 @@ clean_user_essentials() { [[ "$trash_count" =~ ^[0-9]+$ ]] || trash_count="0" if [[ "$DRY_RUN" == "true" ]]; then - [[ $trash_count -gt 0 ]] && echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty ($trash_count items)" || echo -e " ${GRAY}${ICON_EMPTY}${NC} Trash · already empty" + [[ $trash_count -gt 0 ]] && echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty, $trash_count items" || echo -e " ${GRAY}${ICON_EMPTY}${NC} Trash · already empty" elif [[ $trash_count -gt 0 ]]; then if osascript -e 'tell application "Finder" to empty trash' > /dev/null 2>&1; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied ($trash_count items)" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied, $trash_count items" note_activity else safe_clean ~/.Trash/* "Trash" @@ -97,9 +97,9 @@ clean_chrome_old_versions() { local size_human size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Chrome old versions ${YELLOW}(${cleaned_count} dirs, $size_human dry)${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Chrome old versions${NC}, ${YELLOW}${cleaned_count} dirs, $size_human dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Chrome old versions ${GREEN}(${cleaned_count} dirs, $size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Chrome old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" fi ((files_cleaned += cleaned_count)) ((total_size_cleaned += total_size)) @@ -183,9 +183,9 @@ clean_edge_old_versions() { local size_human size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge old versions ${YELLOW}(${cleaned_count} dirs, $size_human dry)${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge old versions${NC}, ${YELLOW}${cleaned_count} dirs, $size_human dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge old versions ${GREEN}(${cleaned_count} dirs, $size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" fi ((files_cleaned += cleaned_count)) ((total_size_cleaned += total_size)) @@ -245,9 +245,9 @@ clean_edge_updater_old_versions() { local size_human size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge updater old versions ${YELLOW}(${cleaned_count} dirs, $size_human dry)${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge updater old versions${NC}, ${YELLOW}${cleaned_count} dirs, $size_human dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge updater old versions ${GREEN}(${cleaned_count} dirs, $size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge updater old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" fi ((files_cleaned += cleaned_count)) ((total_size_cleaned += total_size)) @@ -285,12 +285,12 @@ scan_external_volumes() { local network_count=${#network_volumes[@]} if [[ $volume_count -eq 0 ]]; then if [[ $network_count -gt 0 ]]; then - echo -e " ${GRAY}${ICON_LIST}${NC} External volumes (${network_count} network volume(s) skipped)" + echo -e " ${GRAY}${ICON_LIST}${NC} External volumes, ${network_count} network volumes skipped" note_activity fi return 0 fi - start_section_spinner "Scanning $volume_count external volume(s)..." + start_section_spinner "Scanning $volume_count external volumes..." for volume in "${candidate_volumes[@]}"; do [[ -d "$volume" && -r "$volume" ]] || continue local volume_trash="$volume/.Trashes" @@ -300,7 +300,7 @@ scan_external_volumes() { done < <(command find "$volume_trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) fi if [[ "$PROTECT_FINDER_METADATA" != "true" ]]; then - clean_ds_store_tree "$volume" "$(basename "$volume") volume (.DS_Store)" + clean_ds_store_tree "$volume" "$(basename "$volume") volume, .DS_Store" fi done stop_section_spinner @@ -310,7 +310,7 @@ clean_finder_metadata() { if [[ "$PROTECT_FINDER_METADATA" == "true" ]]; then return fi - clean_ds_store_tree "$HOME" "Home directory (.DS_Store)" + clean_ds_store_tree "$HOME" "Home directory, .DS_Store" } # macOS system caches and user-level leftovers. clean_macos_system_caches() { @@ -389,7 +389,7 @@ clean_mail_downloads() { done if [[ $count -gt 0 ]]; then local cleaned_mb=$(echo "$cleaned_kb" | awk '{printf "%.1f", $1/1024}' || echo "0.0") - echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $count mail attachments (~${cleaned_mb}MB)" + echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $count mail attachments, about ${cleaned_mb}MB" note_activity fi } @@ -418,9 +418,9 @@ clean_sandboxed_app_caches() { if [[ "$found_any" == "true" ]]; then local size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches ${YELLOW}($size_human dry)${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches${NC}, ${YELLOW}$size_human dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches ${GREEN}($size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${GREEN}$size_human${NC}" fi ((files_cleaned += cleaned_count)) ((total_size_cleaned += total_size)) @@ -603,9 +603,9 @@ clean_application_support_logs() { if [[ "$found_any" == "true" ]]; then local size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches ${YELLOW}($size_human dry)${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC}, ${YELLOW}$size_human dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches ${GREEN}($size_human)${NC}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${GREEN}$size_human${NC}" fi ((files_cleaned += cleaned_count)) ((total_size_cleaned += total_size)) diff --git a/lib/core/base.sh b/lib/core/base.sh index 92ae743..3c9e4c0 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -687,7 +687,7 @@ update_progress_if_needed() { if [[ $((current_time - last_time)) -ge $interval ]]; then # Update the spinner with progress stop_section_spinner - start_section_spinner "Scanning items... ($completed/$total)" + start_section_spinner "Scanning items... $completed/$total" # Update the last_update_time variable eval "$last_update_var=$current_time" @@ -717,7 +717,7 @@ push_spinner_state() { fi MOLE_SPINNER_STACK+=("$current_state") - debug_log "Pushed spinner state: $current_state (stack depth: ${#MOLE_SPINNER_STACK[@]})" + debug_log "Pushed spinner state: $current_state, stack depth: ${#MOLE_SPINNER_STACK[@]}" } # Pop and restore spinner state from stack @@ -730,7 +730,7 @@ pop_spinner_state() { # Stack depth safety check if [[ ${#MOLE_SPINNER_STACK[@]} -gt 10 ]]; then - debug_log "Warning: Spinner stack depth excessive (${#MOLE_SPINNER_STACK[@]}), possible leak" + debug_log "Warning: Spinner stack depth excessive, ${#MOLE_SPINNER_STACK[@]}, possible leak" fi local last_idx=$((${#MOLE_SPINNER_STACK[@]} - 1)) @@ -745,7 +745,7 @@ pop_spinner_state() { done MOLE_SPINNER_STACK=("${new_stack[@]}") - debug_log "Popped spinner state: $state (remaining depth: ${#MOLE_SPINNER_STACK[@]})" + debug_log "Popped spinner state: $state, remaining depth: ${#MOLE_SPINNER_STACK[@]}" # Restore state if needed if [[ "$state" == running:* ]]; then @@ -822,7 +822,7 @@ get_terminal_info() { local info="Terminal: ${TERM:-unknown}" if is_ansi_supported; then - info+=" (ANSI supported)" + info+=", ANSI supported" if command -v tput > /dev/null 2>&1; then local cols=$(tput cols 2> /dev/null || echo "?") @@ -831,7 +831,7 @@ get_terminal_info() { info+=" ${cols}x${lines}, ${colors} colors" fi else - info+=" (ANSI not supported)" + info+=", ANSI not supported" fi echo "$info" @@ -852,11 +852,11 @@ validate_terminal_environment() { # Check if running in a known problematic terminal case "${TERM:-}" in dumb) - log_warning "Running in 'dumb' terminal - limited functionality" + log_warning "Running in 'dumb' terminal, limited functionality" ((warnings++)) ;; unknown) - log_warning "Terminal type unknown - may have display issues" + log_warning "Terminal type unknown, may have display issues" ((warnings++)) ;; esac @@ -865,7 +865,7 @@ validate_terminal_environment() { if command -v tput > /dev/null 2>&1; then local cols=$(tput cols 2> /dev/null || echo "80") if [[ "$cols" -lt 60 ]]; then - log_warning "Terminal width ($cols cols) is narrow - output may wrap" + log_warning "Terminal width, $cols cols, is narrow, output may wrap" ((warnings++)) fi fi diff --git a/lib/core/common.sh b/lib/core/common.sh index 75da002..488f7f2 100755 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -81,7 +81,7 @@ update_via_homebrew() { installed_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') [[ -z "$installed_version" ]] && installed_version=$(mo --version 2> /dev/null | awk '/Mole version/ {print $3; exit}') echo "" - echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version (${installed_version:-$current_version})" + 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" @@ -93,7 +93,7 @@ update_via_homebrew() { new_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') [[ -z "$new_version" ]] && new_version=$(mo --version 2> /dev/null | awk '/Mole version/ {print $3; exit}') echo "" - echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-$current_version})" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, ${new_version:-$current_version}" echo "" fi diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 24075ec..ccff245 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -211,7 +211,7 @@ safe_remove() { MOLE_PERMISSION_DENIED_COUNT=${MOLE_PERMISSION_DENIED_COUNT:-0} MOLE_PERMISSION_DENIED_COUNT=$((MOLE_PERMISSION_DENIED_COUNT + 1)) export MOLE_PERMISSION_DENIED_COUNT - debug_log "Permission denied: $path (may need Full Disk Access)" + debug_log "Permission denied: $path, may need Full Disk Access" else [[ "$silent" != "true" ]] && log_error "Failed to remove: $path" fi @@ -267,20 +267,20 @@ safe_sudo_remove() { fi fi - debug_file_action "[DRY RUN] Would remove (sudo)" "$path" "$file_size" "$file_age" + debug_file_action "[DRY RUN] Would remove, sudo" "$path" "$file_size" "$file_age" else - debug_log "[DRY RUN] Would remove (sudo): $path" + debug_log "[DRY RUN] Would remove, sudo: $path" fi return 0 fi - debug_log "Removing (sudo): $path" + debug_log "Removing, sudo: $path" # Perform the deletion if sudo rm -rf "$path" 2> /dev/null; then # SAFE: safe_sudo_remove implementation return 0 else - log_error "Failed to remove (sudo): $path" + log_error "Failed to remove, sudo: $path" return 1 fi } @@ -309,11 +309,11 @@ safe_find_delete() { # Validate type filter if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then - log_error "Invalid type filter: $type_filter (must be 'f' or 'd')" + 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)" + debug_log "Finding in $base_dir: $pattern, age: ${age_days}d, type: $type_filter" local find_args=("-maxdepth" "5" "-name" "$pattern" "-type" "$type_filter") if [[ "$age_days" -gt 0 ]]; then @@ -340,7 +340,7 @@ safe_sudo_find_delete() { # Validate base directory (use sudo for permission-restricted dirs) if ! sudo test -d "$base_dir" 2> /dev/null; then - debug_log "Directory does not exist (skipping): $base_dir" + debug_log "Directory does not exist, skipping: $base_dir" return 0 fi @@ -351,11 +351,11 @@ safe_sudo_find_delete() { # Validate type filter if [[ "$type_filter" != "f" && "$type_filter" != "d" ]]; then - log_error "Invalid type filter: $type_filter (must be 'f' or 'd')" + 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)" + debug_log "Finding, sudo, in $base_dir: $pattern, age: ${age_days}d, type: $type_filter" local find_args=("-maxdepth" "5" "-name" "$pattern" "-type" "$type_filter") if [[ "$age_days" -gt 0 ]]; then diff --git a/lib/core/log.sh b/lib/core/log.sh index 124ee7b..8d7f085 100644 --- a/lib/core/log.sh +++ b/lib/core/log.sh @@ -138,10 +138,9 @@ debug_file_action() { local file_age="${4:-}" if [[ "${MO_DEBUG:-}" == "1" ]]; then - local msg=" - $file_path" - [[ -n "$file_size" ]] && msg+=" ($file_size" + local msg=" * $file_path" + [[ -n "$file_size" ]] && msg+=", $file_size" [[ -n "$file_age" ]] && msg+=", ${file_age} days old" - [[ -n "$file_size" ]] && msg+=")" # Output to stderr echo -e "${GRAY}[DEBUG] $action: $msg${NC}" >&2 @@ -165,10 +164,10 @@ debug_risk_level() { esac # Output to stderr with color - echo -e "${GRAY}[DEBUG] Risk Level: ${color}${risk_level}${GRAY} ($reason)${NC}" >&2 + echo -e "${GRAY}[DEBUG] Risk Level: ${color}${risk_level}${GRAY}, $reason${NC}" >&2 # Also log to file - echo "Risk Level: $risk_level ($reason)" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "Risk Level: $risk_level, $reason" >> "$DEBUG_LOG_FILE" 2> /dev/null || true fi } @@ -187,16 +186,16 @@ log_system_info() { # Start block in debug log file { echo "----------------------------------------------------------------------" - echo "Mole Debug Session - $(date '+%Y-%m-%d %H:%M:%S')" + echo "Mole Debug Session, $(date '+%Y-%m-%d %H:%M:%S')" echo "----------------------------------------------------------------------" echo "User: $USER" echo "Hostname: $(hostname)" echo "Architecture: $(uname -m)" echo "Kernel: $(uname -r)" if command -v sw_vers > /dev/null; then - echo "macOS: $(sw_vers -productVersion) ($(sw_vers -buildVersion))" + echo "macOS: $(sw_vers -productVersion), $(sw_vers -buildVersion)" fi - echo "Shell: ${SHELL:-unknown} (${TERM:-unknown})" + echo "Shell: ${SHELL:-unknown}, ${TERM:-unknown}" # Check sudo status non-interactively if sudo -n true 2> /dev/null; then diff --git a/lib/core/sudo.sh b/lib/core/sudo.sh index 5431db3..d7de287 100644 --- a/lib/core/sudo.sh +++ b/lib/core/sudo.sh @@ -60,7 +60,7 @@ _request_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" + echo -e "${GRAY}Note: Touch ID dialog may appear once more, just cancel it${NC}" > "$tty_path" show_hint=false fi @@ -143,7 +143,7 @@ request_sudo_access() { fi # Touch ID is available and not in clamshell mode - echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg} ${GRAY}(Touch ID or password)${NC}" + 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 & diff --git a/lib/core/timeout.sh b/lib/core/timeout.sh index 5ff9576..ae9779a 100644 --- a/lib/core/timeout.sh +++ b/lib/core/timeout.sh @@ -100,7 +100,7 @@ run_with_timeout() { # ======================================================================== if [[ "${MO_DEBUG:-0}" == "1" ]]; then - echo "[TIMEOUT] Shell fallback (${duration}s): $*" >&2 + echo "[TIMEOUT] Shell fallback, ${duration}s: $*" >&2 fi # Start command in background diff --git a/lib/manage/purge_paths.sh b/lib/manage/purge_paths.sh index bd163bd..0fddca2 100644 --- a/lib/manage/purge_paths.sh +++ b/lib/manage/purge_paths.sh @@ -58,7 +58,7 @@ manage_purge_paths() { if [[ -d "$path" ]]; then echo -e " ${GREEN}✓${NC} $display_path" else - echo -e " ${GRAY}○${NC} $display_path ${GRAY}(not found)${NC}" + echo -e " ${GRAY}○${NC} $display_path${GRAY}, not found${NC}" fi done fi @@ -76,7 +76,7 @@ manage_purge_paths() { echo "" if [[ $custom_count -gt 0 ]]; then - echo -e "${GRAY}Using custom config with $custom_count path(s)${NC}" + echo -e "${GRAY}Using custom config with $custom_count paths${NC}" else echo -e "${GRAY}Using ${#DEFAULT_PURGE_SEARCH_PATHS[@]} default paths${NC}" fi diff --git a/lib/manage/update.sh b/lib/manage/update.sh index b700f31..07cfb57 100644 --- a/lib/manage/update.sh +++ b/lib/manage/update.sh @@ -18,14 +18,14 @@ format_brew_update_label() { ((formulas > 0)) && details+=("${formulas} formula") ((casks > 0)) && details+=("${casks} cask") - local detail_str="(${total} updates)" + local detail_str=", ${total} updates" if ((${#details[@]} > 0)); then - detail_str="($( + detail_str=", $( IFS=', ' printf '%s' "${details[*]}" - ))" + )" fi - printf " • Homebrew %s" "$detail_str" + printf " • Homebrew%s" "$detail_str" } brew_has_outdated() { @@ -54,7 +54,7 @@ ask_for_updates() { if [[ -n "${APPSTORE_UPDATE_COUNT:-}" && "${APPSTORE_UPDATE_COUNT:-0}" -gt 0 ]]; then has_updates=true - update_list+=(" • App Store (${APPSTORE_UPDATE_COUNT} apps)") + update_list+=(" • App Store, ${APPSTORE_UPDATE_COUNT} apps") fi if [[ -n "${MACOS_UPDATE_AVAILABLE:-}" && "${MACOS_UPDATE_AVAILABLE}" == "true" ]]; then @@ -132,10 +132,10 @@ perform_updates() { echo -e "${GRAY}No updates to perform${NC}" return 0 elif [[ $updated_count -eq $total_count ]]; then - echo -e "${GREEN}All updates completed (${updated_count}/${total_count})${NC}" + echo -e "${GREEN}All updates completed, ${updated_count}/${total_count}${NC}" return 0 else - echo -e "${RED}Update failed (${updated_count}/${total_count})${NC}" + echo -e "${RED}Update failed, ${updated_count}/${total_count}${NC}" return 1 fi } diff --git a/lib/manage/whitelist.sh b/lib/manage/whitelist.sh index 23f5d91..e322b6f 100755 --- a/lib/manage/whitelist.sh +++ b/lib/manage/whitelist.sh @@ -148,7 +148,7 @@ CloudKit cache|$HOME/Library/Caches/CloudKit/*|system_cache Trash|$HOME/.Trash|system_cache EOF # Add FINDER_METADATA with constant reference - echo "Finder metadata (.DS_Store)|$FINDER_METADATA_SENTINEL|system_cache" + echo "Finder metadata, .DS_Store|$FINDER_METADATA_SENTINEL|system_cache" } # Get all optimize items with their patterns @@ -284,13 +284,13 @@ manage_whitelist_categories() { items_source=$(get_optimize_whitelist_items) active_config_file="$WHITELIST_CONFIG_OPTIMIZE" local display_config="${active_config_file/#$HOME/~}" - menu_title="Whitelist Manager – Select system checks to ignore + menu_title="Whitelist Manager, Select system checks to ignore ${GRAY}Edit: ${display_config}${NC}" else items_source=$(get_all_cache_items) active_config_file="$WHITELIST_CONFIG_CLEAN" local display_config="${active_config_file/#$HOME/~}" - menu_title="Whitelist Manager – Select caches to protect + menu_title="Whitelist Manager, Select caches to protect ${GRAY}Edit: ${display_config}${NC}" fi @@ -416,7 +416,7 @@ ${GRAY}Edit: ${display_config}${NC}" if [[ ${#custom_patterns[@]} -gt 0 ]]; then summary_lines+=("Protected ${#selected_patterns[@]} predefined + ${#custom_patterns[@]} custom patterns") else - summary_lines+=("Protected ${total_protected} cache(s)") + summary_lines+=("Protected ${total_protected} caches") fi local display_config="${active_config_file/#$HOME/~}" summary_lines+=("Config: ${GRAY}${display_config}${NC}") diff --git a/lib/optimize/tasks.sh b/lib/optimize/tasks.sh index 042e0f2..ea0a1fa 100644 --- a/lib/optimize/tasks.sh +++ b/lib/optimize/tasks.sh @@ -263,7 +263,7 @@ opt_sqlite_vacuum() { fi if ! command -v sqlite3 > /dev/null 2>&1; then - echo -e " ${GRAY}-${NC} Database optimization already optimal (sqlite3 unavailable)" + echo -e " ${GRAY}-${NC} Database optimization already optimal, sqlite3 unavailable" return 0 fi @@ -584,7 +584,7 @@ opt_disk_permissions_repair() { opt_msg "User directory permissions repaired" opt_msg "File access issues resolved" else - echo -e " ${YELLOW}!${NC} Failed to repair permissions (may not be needed)" + echo -e " ${YELLOW}!${NC} Failed to repair permissions, may not be needed" fi else opt_msg "User directory permissions repaired" @@ -705,7 +705,7 @@ opt_spotlight_index_optimize() { fi if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - echo -e " ${BLUE}ℹ${NC} Spotlight search is slow, rebuilding index (may take 1-2 hours)" + echo -e " ${BLUE}ℹ${NC} Spotlight search is slow, rebuilding index, may take 1-2 hours" if sudo mdutil -E / > /dev/null 2>&1; then opt_msg "Spotlight index rebuild started" echo -e " ${GRAY}Indexing will continue in background${NC}" diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 85cc952..2bbc2df 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -307,7 +307,7 @@ batch_uninstall_applications() { local brew_tag="" [[ "$is_brew_cask" == "true" ]] && brew_tag=" ${CYAN}[Brew]${NC}" - echo -e "${BLUE}${ICON_CONFIRM}${NC} ${app_name}${brew_tag} ${GRAY}(${app_size_display})${NC}" + echo -e "${BLUE}${ICON_CONFIRM}${NC} ${app_name}${brew_tag} ${GRAY}, ${app_size_display}${NC}" # Show detailed file list for ALL apps (brew casks leave user data behind) local related_files=$(decode_file_list "$encoded_files" "$app_name") @@ -352,7 +352,7 @@ batch_uninstall_applications() { echo "" local removal_note="Remove ${app_total} ${app_text}" - [[ -n "$size_display" ]] && removal_note+=" (${size_display})" + [[ -n "$size_display" ]] && removal_note+=", ${size_display}" if [[ ${#running_apps[@]} -gt 0 ]]; then removal_note+=" ${YELLOW}[Running]${NC}" fi @@ -516,7 +516,7 @@ batch_uninstall_applications() { # Show failure if [[ -t 1 ]]; then if [[ ${#app_details[@]} -gt 1 ]]; then - echo -e "${ICON_ERROR} [$current_index/${#app_details[@]}] ${app_name} ${GRAY}($reason)${NC}" + echo -e "${ICON_ERROR} [$current_index/${#app_details[@]}] ${app_name} ${GRAY}, $reason${NC}" else echo -e "${ICON_ERROR} ${app_name} failed: $reason" fi @@ -592,7 +592,7 @@ batch_uninstall_applications() { still*running*) reason_summary="is still running" ;; remove*failed*) reason_summary="could not be removed" ;; permission*denied*) reason_summary="permission denied" ;; - owned*by*) reason_summary="$first_reason (try with sudo)" ;; + owned*by*) reason_summary="$first_reason, try with sudo" ;; *) reason_summary="$first_reason" ;; esac fi diff --git a/mole b/mole index f8ae12c..8761cb1 100755 --- a/mole +++ b/mole @@ -247,14 +247,14 @@ update_mole() { if [[ -z "$latest" ]]; then log_error "Unable to check for updates. Check network connection." - echo -e "${YELLOW}Tip:${NC} Check if you can access GitHub (https://github.com)" + echo -e "${YELLOW}Tip:${NC} Check if you can access GitHub, https://github.com" echo -e "${YELLOW}Tip:${NC} Try again with: ${GRAY}mo update${NC}" exit 1 fi if [[ "$VERSION" == "$latest" && "$force_update" != "true" ]]; then echo "" - echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version (${VERSION})" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version, ${VERSION}" echo "" exit 0 fi @@ -278,7 +278,7 @@ update_mole() { local curl_exit=$? if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" - log_error "Update failed (curl error: $curl_exit)" + log_error "Update failed, curl error: $curl_exit" case $curl_exit in 6) echo -e "${YELLOW}Tip:${NC} Could not resolve host. Check DNS or network connection." ;; @@ -294,7 +294,7 @@ update_mole() { download_error=$(wget --timeout=10 --tries=3 -qO "$tmp_installer" "$installer_url" 2>&1) || { if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" - log_error "Update failed (wget error)" + log_error "Update failed, wget error" echo -e "${YELLOW}Tip:${NC} Check network connection and try again." echo -e "${YELLOW}Tip:${NC} URL: $installer_url" exit 1 @@ -324,7 +324,7 @@ update_mole() { if [[ "$requires_sudo" == "true" ]]; then if ! request_sudo_access "Mole update requires admin access"; then - log_error "Update aborted (admin access denied)" + log_error "Update aborted, admin access denied" rm -f "$tmp_installer" exit 1 fi @@ -349,14 +349,17 @@ update_mole() { if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then local new_version - new_version=$(printf '%s\n' "$output" | sed -n 's/.*(version \([^)]*\)).*/\1/p' | head -1) + new_version=$(printf '%s\n' "$output" | sed -n 's/.*-> \([^[:space:]]\{1,\}\).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$(printf '%s\n' "$output" | sed -n 's/.*version[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*/\1/p' | head -1) + fi if [[ -z "$new_version" ]]; then new_version=$("$mole_path" --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") fi if [[ -z "$new_version" ]]; then new_version="$fallback_version" fi - printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-unknown})" + printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, ${new_version:-unknown}" else printf '\n' fi @@ -484,15 +487,15 @@ remove_mole() { exit 0 fi - echo -e "${YELLOW}Remove Mole${NC} - will delete the following:" + echo -e "${YELLOW}Remove Mole${NC}, will delete the following:" if [[ "$is_homebrew" == "true" ]]; then - echo " - Mole via Homebrew" + echo " * Mole via Homebrew" fi for install in ${manual_installs[@]+"${manual_installs[@]}"} ${alias_installs[@]+"${alias_installs[@]}"}; do - echo " - $install" + echo " * $install" done - echo " - ~/.config/mole" - echo " - ~/.cache/mole" + echo " * ~/.config/mole" + echo " * ~/.cache/mole" echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to confirm, ${GRAY}ESC${NC} to cancel: " IFS= read -r -s -n1 key || key="" diff --git a/scripts/check.sh b/scripts/check.sh index 4249a82..20e3dff 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -14,7 +14,7 @@ usage() { Usage: ./scripts/check.sh [--format|--no-format] Options: - --format Apply formatting fixes only (shfmt, gofmt) + --format Apply formatting fixes only, shfmt, gofmt --no-format Skip formatting and run checks only --help Show this help EOF @@ -55,7 +55,7 @@ readonly ICON_ERROR="☻" readonly ICON_WARNING="●" readonly ICON_LIST="•" -echo -e "${BLUE}=== Mole Check (${MODE}) ===${NC}\n" +echo -e "${BLUE}=== Mole Check, ${MODE} ===${NC}\n" SHELL_FILES=$(find . -type f \( -name "*.sh" -o -name "mole" \) \ -not -path "./.git/*" \ @@ -75,11 +75,11 @@ if [[ "$MODE" == "format" ]]; then fi if command -v goimports > /dev/null 2>&1; then - echo -e "${YELLOW}Formatting Go code (goimports)...${NC}" + echo -e "${YELLOW}Formatting Go code, goimports...${NC}" goimports -w -local github.com/tw93/Mole ./cmd echo -e "${GREEN}${ICON_SUCCESS} Go formatting complete${NC}\n" elif command -v go > /dev/null 2>&1; then - echo -e "${YELLOW}Formatting Go code (gofmt)...${NC}" + echo -e "${YELLOW}Formatting Go code, gofmt...${NC}" gofmt -w ./cmd echo -e "${GREEN}${ICON_SUCCESS} Go formatting complete${NC}\n" else @@ -100,11 +100,11 @@ if [[ "$MODE" != "check" ]]; then fi if command -v goimports > /dev/null 2>&1; then - echo -e "${YELLOW}2. Formatting Go code (goimports)...${NC}" + echo -e "${YELLOW}2. Formatting Go code, goimports...${NC}" goimports -w -local github.com/tw93/Mole ./cmd echo -e "${GREEN}${ICON_SUCCESS} Go formatting applied${NC}\n" elif command -v go > /dev/null 2>&1; then - echo -e "${YELLOW}2. Formatting Go code (gofmt)...${NC}" + echo -e "${YELLOW}2. Formatting Go code, gofmt...${NC}" gofmt -w ./cmd echo -e "${GREEN}${ICON_SUCCESS} Go formatting applied${NC}\n" fi @@ -148,18 +148,18 @@ fi echo -e "${YELLOW}5. Running syntax check...${NC}" if ! bash -n mole; then - echo -e "${RED}${ICON_ERROR} Syntax check failed (mole)${NC}\n" + echo -e "${RED}${ICON_ERROR} Syntax check failed, mole${NC}\n" exit 1 fi for script in bin/*.sh; do if ! bash -n "$script"; then - echo -e "${RED}${ICON_ERROR} Syntax check failed ($script)${NC}\n" + echo -e "${RED}${ICON_ERROR} Syntax check failed, $script${NC}\n" exit 1 fi done find lib -name "*.sh" | while read -r script; do if ! bash -n "$script"; then - echo -e "${RED}${ICON_ERROR} Syntax check failed ($script)${NC}\n" + echo -e "${RED}${ICON_ERROR} Syntax check failed, $script${NC}\n" exit 1 fi done diff --git a/scripts/setup-quick-launchers.sh b/scripts/setup-quick-launchers.sh index 11b8784..53b07f3 100755 --- a/scripts/setup-quick-launchers.sh +++ b/scripts/setup-quick-launchers.sh @@ -392,7 +392,7 @@ ${command} EOF - log_success "Workflow ready: ${name} (keyword: ${keyword})" + log_success "Workflow ready: ${name}, keyword: ${keyword}" done log_step "Open Alfred preferences → Workflows if you need to adjust keywords." @@ -413,11 +413,11 @@ main() { echo "" log_success "Done! Raycast and Alfred are ready with 5 commands:" - echo " • clean - Deep system cleanup" - echo " • uninstall - Remove applications" - echo " • optimize - System health & tuning" - echo " • analyze - Disk space explorer" - echo " • status - Live system monitor" + echo " • clean, Deep system cleanup" + echo " • uninstall, Remove applications" + echo " • optimize, System health & tuning" + echo " • analyze, Disk space explorer" + echo " • status, Live system monitor" echo "" } diff --git a/scripts/test.sh b/scripts/test.sh index 11bc688..d42905c 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -183,7 +183,7 @@ echo "" echo "6. Testing installation..." # Skip if Homebrew mole is installed (install.sh will refuse to overwrite) if brew list mole &> /dev/null; then - printf "${GREEN}${ICON_SUCCESS} Installation test skipped (Homebrew)${NC}\n" + printf "${GREEN}${ICON_SUCCESS} Installation test skipped, Homebrew${NC}\n" elif ./install.sh --prefix /tmp/mole-test > /dev/null 2>&1; then if [ -f /tmp/mole-test/mole ]; then printf "${GREEN}${ICON_SUCCESS} Installation test passed${NC}\n" @@ -203,5 +203,5 @@ if [[ $FAILED -eq 0 ]]; then printf "${GREEN}${ICON_SUCCESS} All tests passed!${NC}\n" exit 0 fi -printf "${RED}${ICON_ERROR} $FAILED test(s) failed!${NC}\n" +printf "${RED}${ICON_ERROR} $FAILED tests failed!${NC}\n" exit 1 diff --git a/tests/update.bats b/tests/update.bats index 055603a..a02fd75 100644 --- a/tests/update.bats +++ b/tests/update.bats @@ -78,8 +78,8 @@ ask_for_updates EOF [ "$status" -eq 1 ] # ESC cancels - [[ "$output" == *"Homebrew (5 updates)"* ]] - [[ "$output" == *"App Store (1 apps)"* ]] + [[ "$output" == *"Homebrew, 3 formula, 2 cask"* ]] + [[ "$output" == *"App Store, 1 apps"* ]] [[ "$output" == *"macOS system"* ]] [[ "$output" == *"Mole"* ]] } @@ -253,24 +253,27 @@ process_install_output() { if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then local new_version - new_version=$(printf '%s\n' "$output" | sed -n 's/.*(version \([^)]*\)).*/\1/p' | head -1) + new_version=$(printf '%s\n' "$output" | sed -n 's/.*-> \([^[:space:]]\{1,\}\).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$(printf '%s\n' "$output" | sed -n 's/.*version[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*/\1/p' | head -1) + fi if [[ -z "$new_version" ]]; then new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") fi if [[ -z "$new_version" ]]; then new_version="$fallback_version" fi - printf '\n%s\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-unknown})" + printf '\n%s\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, ${new_version:-unknown}" fi } output="Installing Mole... -◎ Mole installed successfully (version 1.23.1)" +◎ Mole installed successfully, version 1.23.1" process_install_output "$output" "1.23.0" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Updated to latest version (1.23.1)"* ]] + [[ "$output" == *"Updated to latest version, 1.23.1"* ]] [[ "$output" != *"1.23.0"* ]] } @@ -293,14 +296,17 @@ process_install_output() { if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then local new_version - new_version=$(printf '%s\n' "$output" | sed -n 's/.*(version \([^)]*\)).*/\1/p' | head -1) + new_version=$(printf '%s\n' "$output" | sed -n 's/.*-> \([^[:space:]]\{1,\}\).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$(printf '%s\n' "$output" | sed -n 's/.*version[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*/\1/p' | head -1) + fi if [[ -z "$new_version" ]]; then new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") fi if [[ -z "$new_version" ]]; then new_version="$fallback_version" fi - printf '\n%s\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-unknown})" + printf '\n%s\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, ${new_version:-unknown}" fi } @@ -311,7 +317,7 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Installation completed"* ]] - [[ "$output" == *"Updated to latest version (1.23.1)"* ]] + [[ "$output" == *"Updated to latest version, 1.23.1"* ]] } @test "process_install_output handles empty output with fallback version" { @@ -333,14 +339,17 @@ process_install_output() { if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then local new_version - new_version=$(printf '%s\n' "$output" | sed -n 's/.*(version \([^)]*\)).*/\1/p' | head -1) + new_version=$(printf '%s\n' "$output" | sed -n 's/.*-> \([^[:space:]]\{1,\}\).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$(printf '%s\n' "$output" | sed -n 's/.*version[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*/\1/p' | head -1) + fi if [[ -z "$new_version" ]]; then new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") fi if [[ -z "$new_version" ]]; then new_version="$fallback_version" fi - printf '\n%s\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-unknown})" + printf '\n%s\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, ${new_version:-unknown}" fi } @@ -349,7 +358,7 @@ process_install_output "$output" "1.23.1" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Updated to latest version (1.23.1)"* ]] + [[ "$output" == *"Updated to latest version, 1.23.1"* ]] } @test "process_install_output does not extract wrong parentheses content" { @@ -371,14 +380,17 @@ process_install_output() { if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then local new_version - new_version=$(printf '%s\n' "$output" | sed -n 's/.*(version \([^)]*\)).*/\1/p' | head -1) + new_version=$(printf '%s\n' "$output" | sed -n 's/.*-> \([^[:space:]]\{1,\}\).*/\1/p' | head -1) + if [[ -z "$new_version" ]]; then + new_version=$(printf '%s\n' "$output" | sed -n 's/.*version[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*/\1/p' | head -1) + fi if [[ -z "$new_version" ]]; then new_version=$(command -v mo > /dev/null 2>&1 && mo --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") fi if [[ -z "$new_version" ]]; then new_version="$fallback_version" fi - printf '\n%s\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-unknown})" + printf '\n%s\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, ${new_version:-unknown}" fi } @@ -389,7 +401,7 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Downloading (progress: 100%)"* ]] - [[ "$output" == *"Updated to latest version (1.23.1)"* ]] + [[ "$output" == *"Updated to latest version, 1.23.1"* ]] [[ "$output" != *"progress: 100%"* ]] || [[ "$output" == *"Downloading (progress: 100%)"* ]] } @@ -418,7 +430,7 @@ curl() { if [[ -n "$out" ]]; then cat > "$out" << 'INSTALLER' #!/usr/bin/env bash -echo "Mole installed successfully (version $CURRENT_VERSION)" +echo "Mole installed successfully, version $CURRENT_VERSION" INSTALLER return 0 fi From e0aba780c8a6482bff9068af297dcc99d3012d42 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 26 Jan 2026 14:36:12 +0800 Subject: [PATCH 74/91] feat: remove automatic Rosetta 2 installation prompt - Change Rosetta 2 status from warning to info - Remove auto-fix suggestion for missing Rosetta 2 - Update tests to reflect new behavior (3 -> 2 expected fixes) --- lib/check/all.sh | 17 ++++++++--------- lib/manage/autofix.sh | 6 +++--- tests/manage_autofix.bats | 10 +--------- 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/lib/check/all.sh b/lib/check/all.sh index f23dd70..e2324e3 100644 --- a/lib/check/all.sh +++ b/lib/check/all.sh @@ -57,13 +57,12 @@ check_touchid_sudo() { check_rosetta() { # Check whitelist if command -v is_whitelisted > /dev/null && is_whitelisted "check_rosetta"; then return; fi - # Check Rosetta 2 (for Apple Silicon Macs) + # Check Rosetta 2 (for Apple Silicon Macs) - informational only, not auto-fixed if [[ "$(uname -m)" == "arm64" ]]; then if [[ -f "/Library/Apple/usr/share/rosetta/rosetta" ]]; then echo -e " ${GREEN}✓${NC} Rosetta 2 Intel app translation ready" else - echo -e " ${GRAY}${ICON_WARNING}${NC} Rosetta 2 ${YELLOW}Intel app support missing${NC}" - export ROSETTA_NOT_INSTALLED=true + echo -e " ${GRAY}${ICON_EMPTY}${NC} Rosetta 2 ${GRAY}Not installed${NC}" fi fi } @@ -344,7 +343,7 @@ check_mole_update() { # Compare versions if [[ "$(printf '%s\n' "$current_version" "$latest_version" | sort -V | head -1)" == "$current_version" ]]; then export MOLE_UPDATE_AVAILABLE="true" - echo -e " ${GRAY}${ICON_WARNING}${NC} Mole ${YELLOW}${latest_version} available${NC} (running ${current_version})" + echo -e " ${GRAY}${ICON_WARNING}${NC} Mole ${YELLOW}${latest_version} available${NC}, running ${current_version}" else echo -e " ${GREEN}✓${NC} Mole Latest version ${current_version}" fi @@ -406,9 +405,9 @@ check_disk_space() { export DISK_FREE_GB=$free_num if [[ $free_num -lt 20 ]]; then - echo -e " ${RED}✗${NC} Disk Space ${RED}${free_gb}GB free${NC} (Critical)" + echo -e " ${RED}✗${NC} Disk Space ${RED}${free_gb}GB free${NC}, Critical" elif [[ $free_num -lt 50 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Disk Space ${YELLOW}${free_gb}GB free${NC} (Low)" + echo -e " ${GRAY}${ICON_WARNING}${NC} Disk Space ${YELLOW}${free_gb}GB free${NC}, Low" else echo -e " ${GREEN}✓${NC} Disk Space ${free_gb}GB free" fi @@ -452,9 +451,9 @@ check_memory_usage() { ((used_percent < 0)) && used_percent=0 if [[ $used_percent -gt 90 ]]; then - echo -e " ${RED}✗${NC} Memory ${RED}${used_percent}% used${NC} (Critical)" + echo -e " ${RED}✗${NC} Memory ${RED}${used_percent}% used${NC}, Critical" elif [[ $used_percent -gt 80 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Memory ${YELLOW}${used_percent}% used${NC} (High)" + echo -e " ${GRAY}${ICON_WARNING}${NC} Memory ${YELLOW}${used_percent}% used${NC}, High" else echo -e " ${GREEN}✓${NC} Memory ${used_percent}% used" fi @@ -568,7 +567,7 @@ check_swap_usage() { if [[ "$swap_used" == *"G"* ]]; then local swap_gb=${swap_num%.*} if [[ $swap_gb -gt 2 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Swap Usage ${YELLOW}${swap_used}${NC} (High)" + echo -e " ${GRAY}${ICON_WARNING}${NC} Swap Usage ${YELLOW}${swap_used}${NC}, High" else echo -e " ${GREEN}✓${NC} Swap Usage ${swap_used}" fi diff --git a/lib/manage/autofix.sh b/lib/manage/autofix.sh index 068add3..4096c00 100644 --- a/lib/manage/autofix.sh +++ b/lib/manage/autofix.sh @@ -56,7 +56,7 @@ show_suggestions() { if [[ -n "${DISK_FREE_GB:-}" && "${DISK_FREE_GB:-0}" -lt 50 ]]; then if [[ -z "${CACHE_SIZE_GB:-}" ]] || (($(echo "${CACHE_SIZE_GB:-0} <= 5" | bc -l 2> /dev/null || echo 1))); then - manual_items+=("Low disk space (${DISK_FREE_GB}GB free)|Run: mo analyze to find large files") + manual_items+=("Low disk space, ${DISK_FREE_GB}GB free|Run: mo analyze to find large files") has_suggestions=true fi fi @@ -127,7 +127,7 @@ perform_auto_fix() { # Ensure sudo access if ! has_sudo_session; then if ! ensure_sudo_session "System fixes require admin access"; then - echo -e "${YELLOW}Skipping auto fixes (admin authentication required)${NC}" + echo -e "${YELLOW}Skipping auto fixes, admin authentication required${NC}" echo "" return 0 fi @@ -176,7 +176,7 @@ auth sufficient pam_tid.so fi if [[ $fixed_count -gt 0 ]]; then - AUTO_FIX_SUMMARY="Auto fixes applied: ${fixed_count} issue(s)" + AUTO_FIX_SUMMARY="Auto fixes applied: ${fixed_count} issues" if [[ ${#fixed_items[@]} -gt 0 ]]; then AUTO_FIX_DETAILS=$(printf '%s\n' "${fixed_items[@]}") else diff --git a/tests/manage_autofix.bats b/tests/manage_autofix.bats index b2f34ef..219d6d2 100644 --- a/tests/manage_autofix.bats +++ b/tests/manage_autofix.bats @@ -14,7 +14,6 @@ source "$PROJECT_ROOT/lib/manage/autofix.sh" export FIREWALL_DISABLED=true export FILEVAULT_DISABLED=true export TOUCHID_NOT_CONFIGURED=true -export ROSETTA_NOT_INSTALLED=true export CACHE_SIZE_GB=9 export BREW_HAS_WARNINGS=true export DISK_FREE_GB=25 @@ -27,7 +26,6 @@ EOF [[ "$output" == *"Enable Firewall for better security"* ]] [[ "$output" == *"Enable FileVault"* ]] [[ "$output" == *"Enable Touch ID for sudo"* ]] - [[ "$output" == *"Install Rosetta 2"* ]] [[ "$output" == *"Low disk space (25GB free)"* ]] [[ "$output" == *"AUTO_FLAG=true"* ]] } @@ -72,10 +70,6 @@ sudo() { case "$1" in defaults) return 0 ;; bash) return 0 ;; - softwareupdate) - echo "Installing Rosetta 2 stub output" - return 0 - ;; /usr/libexec/ApplicationFirewall/socketfilterfw) return 0 ;; *) return 0 ;; esac @@ -83,7 +77,6 @@ sudo() { export FIREWALL_DISABLED=true export TOUCHID_NOT_CONFIGURED=true -export ROSETTA_NOT_INSTALLED=true perform_auto_fix echo "SUMMARY=${AUTO_FIX_SUMMARY}" @@ -93,7 +86,6 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Firewall enabled"* ]] [[ "$output" == *"Touch ID configured"* ]] - [[ "$output" == *"Rosetta 2 installed"* ]] - [[ "$output" == *"SUMMARY=Auto fixes applied: 3 issue(s)"* ]] + [[ "$output" == *"SUMMARY=Auto fixes applied: 2 issues"* ]] [[ "$output" == *"DETAILS"* ]] } From 8c4cd7f82e65b990b1b35d526bc87cd950e9af8d Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 26 Jan 2026 15:22:07 +0800 Subject: [PATCH 75/91] feat: log cleanup operations for troubleshooting --- README.md | 1 + bin/clean.sh | 9 +++ bin/optimize.sh | 91 +++++++++++++++------------- bin/purge.sh | 7 +++ bin/uninstall.sh | 6 ++ lib/core/file_ops.sh | 134 +++++++++++++++++++++++++---------------- lib/core/log.sh | 138 +++++++++++++++++++++++++++++++++++-------- 7 files changed, 267 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 9b7f37e..f816754 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ mo purge --paths # Configure project scan directories - **Safety**: Built with strict protections. See [Security Audit](SECURITY_AUDIT.md). Preview changes with `mo clean --dry-run`. - **Be Careful**: Although safe by design, file deletion is permanent. Please review operations carefully. - **Debug Mode**: Use `--debug` for detailed logs (e.g., `mo clean --debug`). Combine with `--dry-run` for comprehensive preview including risk levels and file details. +- **Operation Log**: File operations are logged to `~/.config/mole/operations.log` for troubleshooting. Disable with `MO_NO_OPLOG=1`. - **Navigation**: Supports arrow keys and Vim bindings (`h/j/k/l`). - **Status Shortcuts**: In `mo status`, press `k` to toggle cat visibility and save preference, `q` to quit. - **Configuration**: Run `mo touchid` for Touch ID sudo, `mo completion` for shell tab completion, `mo clean --whitelist` to manage protected paths. diff --git a/bin/clean.sh b/bin/clean.sh index 4320106..1a182fe 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -365,6 +365,7 @@ safe_clean() { if should_protect_path "$path"; then skip=true ((skipped_count++)) + log_operation "clean" "SKIPPED" "$path" "protected" fi [[ "$skip" == "true" ]] && continue @@ -372,6 +373,7 @@ safe_clean() { if is_path_whitelisted "$path"; then skip=true ((skipped_count++)) + log_operation "clean" "SKIPPED" "$path" "whitelist" fi [[ "$skip" == "true" ]] && continue [[ -e "$path" ]] && existing_paths+=("$path") @@ -699,6 +701,10 @@ safe_clean() { } start_cleanup() { + # Set current command for operation logging + export MOLE_CURRENT_COMMAND="clean" + log_operation_session_start "clean" + if [[ -t 1 ]]; then printf '\033[2J\033[H' fi @@ -1065,6 +1071,9 @@ perform_cleanup() { set -e fi + # Log session end with summary + log_operation_session_end "clean" "$files_cleaned" "$total_size_cleaned" + print_summary_block "$summary_heading" "${summary_details[@]}" printf '\n' } diff --git a/bin/optimize.sh b/bin/optimize.sh index 9fe3864..3a5e0f1 100755 --- a/bin/optimize.sh +++ b/bin/optimize.sh @@ -139,12 +139,12 @@ show_optimization_summary() { show_system_health() { local health_json="$1" - local mem_used=$(echo "$health_json" | jq -r '.memory_used_gb // 0' 2> /dev/null || echo "0") - local mem_total=$(echo "$health_json" | jq -r '.memory_total_gb // 0' 2> /dev/null || echo "0") - local disk_used=$(echo "$health_json" | jq -r '.disk_used_gb // 0' 2> /dev/null || echo "0") - local disk_total=$(echo "$health_json" | jq -r '.disk_total_gb // 0' 2> /dev/null || echo "0") - local disk_percent=$(echo "$health_json" | jq -r '.disk_used_percent // 0' 2> /dev/null || echo "0") - local uptime=$(echo "$health_json" | jq -r '.uptime_days // 0' 2> /dev/null || echo "0") + local mem_used=$(echo "$health_json" | jq -r '.memory_used_gb // 0' 2>/dev/null || echo "0") + local mem_total=$(echo "$health_json" | jq -r '.memory_total_gb // 0' 2>/dev/null || echo "0") + local disk_used=$(echo "$health_json" | jq -r '.disk_used_gb // 0' 2>/dev/null || echo "0") + local disk_total=$(echo "$health_json" | jq -r '.disk_total_gb // 0' 2>/dev/null || echo "0") + local disk_percent=$(echo "$health_json" | jq -r '.disk_used_percent // 0' 2>/dev/null || echo "0") + local uptime=$(echo "$health_json" | jq -r '.uptime_days // 0' 2>/dev/null || echo "0") mem_used=${mem_used:-0} mem_total=${mem_total:-0} @@ -159,7 +159,7 @@ show_system_health() { parse_optimizations() { local health_json="$1" - echo "$health_json" | jq -c '.optimizations[]' 2> /dev/null + echo "$health_json" | jq -c '.optimizations[]' 2>/dev/null } announce_action() { @@ -177,12 +177,12 @@ announce_action() { touchid_configured() { local pam_file="/etc/pam.d/sudo" - [[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2> /dev/null + [[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2>/dev/null } touchid_supported() { - if command -v bioutil > /dev/null 2>&1; then - if bioutil -r 2> /dev/null | grep -qi "Touch ID"; then + if command -v bioutil >/dev/null 2>&1; then + if bioutil -r 2>/dev/null | grep -qi "Touch ID"; then return 0 fi fi @@ -272,7 +272,7 @@ ask_for_security_fixes() { echo "" echo -e "${BLUE}SECURITY FIXES${NC}" for entry in "${SECURITY_FIXES[@]}"; do - IFS='|' read -r _ label <<< "$entry" + IFS='|' read -r _ label <<<"$entry" echo -e " ${ICON_LIST} $label" done echo "" @@ -299,7 +299,7 @@ ask_for_security_fixes() { } apply_firewall_fix() { - if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then + if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on >/dev/null 2>&1; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} Firewall enabled" FIREWALL_DISABLED=false return 0 @@ -309,7 +309,7 @@ apply_firewall_fix() { } apply_gatekeeper_fix() { - if sudo spctl --master-enable 2> /dev/null; then + if sudo spctl --master-enable 2>/dev/null; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} Gatekeeper enabled" GATEKEEPER_DISABLED=false return 0 @@ -333,17 +333,17 @@ perform_security_fixes() { local applied=0 for entry in "${SECURITY_FIXES[@]}"; do - IFS='|' read -r action _ <<< "$entry" + IFS='|' read -r action _ <<<"$entry" case "$action" in - firewall) - apply_firewall_fix && ((applied++)) - ;; - gatekeeper) - apply_gatekeeper_fix && ((applied++)) - ;; - touchid) - apply_touchid_fix && ((applied++)) - ;; + firewall) + apply_firewall_fix && ((applied++)) + ;; + gatekeeper) + apply_gatekeeper_fix && ((applied++)) + ;; + touchid) + apply_touchid_fix && ((applied++)) + ;; esac done @@ -354,9 +354,11 @@ perform_security_fixes() { } cleanup_all() { - stop_inline_spinner 2> /dev/null || true + stop_inline_spinner 2>/dev/null || true stop_sudo_session cleanup_temp_files + # Log session end + log_operation_session_end "optimize" "${OPTIMIZE_SAFE_COUNT:-0}" "0" } handle_interrupt() { @@ -365,22 +367,27 @@ handle_interrupt() { } main() { + # Set current command for operation logging + export MOLE_CURRENT_COMMAND="optimize" + local health_json for arg in "$@"; do case "$arg" in - "--debug") - export MO_DEBUG=1 - ;; - "--dry-run") - export MOLE_DRY_RUN=1 - ;; - "--whitelist") - manage_whitelist "optimize" - exit 0 - ;; + "--debug") + export MO_DEBUG=1 + ;; + "--dry-run") + export MOLE_DRY_RUN=1 + ;; + "--whitelist") + manage_whitelist "optimize" + exit 0 + ;; esac done + log_operation_session_start "optimize" + trap cleanup_all EXIT trap handle_interrupt INT TERM @@ -394,13 +401,13 @@ main() { echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No files will be modified\n" fi - if ! command -v jq > /dev/null 2>&1; then + if ! command -v jq >/dev/null 2>&1; then echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: jq" echo -e "${GRAY}Install with: ${GREEN}brew install jq${NC}" exit 1 fi - if ! command -v bc > /dev/null 2>&1; then + if ! command -v bc >/dev/null 2>&1; then echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: bc" echo -e "${GRAY}Install with: ${GREEN}brew install bc${NC}" exit 1 @@ -410,7 +417,7 @@ main() { start_inline_spinner "Collecting system info..." fi - if ! health_json=$(generate_health_json 2> /dev/null); then + if ! health_json=$(generate_health_json 2>/dev/null); then if [[ -t 1 ]]; then stop_inline_spinner fi @@ -419,7 +426,7 @@ main() { exit 1 fi - if ! echo "$health_json" | jq empty 2> /dev/null; then + if ! echo "$health_json" | jq empty 2>/dev/null; then if [[ -t 1 ]]; then stop_inline_spinner fi @@ -451,7 +458,7 @@ main() { local -a confirm_items=() local opts_file opts_file=$(mktemp_file) - parse_optimizations "$health_json" > "$opts_file" + parse_optimizations "$health_json" >"$opts_file" while IFS= read -r opt_json; do [[ -z "$opt_json" ]] && continue @@ -469,7 +476,7 @@ main() { else confirm_items+=("$item") fi - done < "$opts_file" + done <"$opts_file" echo "" if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then @@ -479,7 +486,7 @@ main() { export FIRST_ACTION=true if [[ ${#safe_items[@]} -gt 0 ]]; then for item in "${safe_items[@]}"; do - IFS='|' read -r name desc action path <<< "$item" + IFS='|' read -r name desc action path <<<"$item" announce_action "$name" "$desc" "safe" execute_optimization "$action" "$path" done @@ -487,7 +494,7 @@ main() { if [[ ${#confirm_items[@]} -gt 0 ]]; then for item in "${confirm_items[@]}"; do - IFS='|' read -r name desc action path <<< "$item" + IFS='|' read -r name desc action path <<<"$item" announce_action "$name" "$desc" "confirm" execute_optimization "$action" "$path" done diff --git a/bin/purge.sh b/bin/purge.sh index eb3af0b..ce67c57 100755 --- a/bin/purge.sh +++ b/bin/purge.sh @@ -42,6 +42,10 @@ note_activity() { # Main purge function start_purge() { + # Set current command for operation logging + export MOLE_CURRENT_COMMAND="purge" + log_operation_session_start "purge" + # Clear screen for better UX if [[ -t 1 ]]; then printf '\033[2J\033[H' @@ -214,6 +218,9 @@ perform_purge() { summary_details+=("Free space now: $(get_free_space)") fi + # Log session end + log_operation_session_end "purge" "${total_items_cleaned:-0}" "${total_size_cleaned:-0}" + print_summary_block "$summary_heading" "${summary_details[@]}" printf '\n' } diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 13b20cc..cc415fc 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -374,6 +374,8 @@ cleanup() { wait "$sudo_keepalive_pid" 2> /dev/null || true sudo_keepalive_pid="" fi + # Log session end + log_operation_session_end "uninstall" "${files_cleaned:-0}" "${total_size_cleaned:-0}" show_cursor exit "${1:-0}" } @@ -381,6 +383,10 @@ cleanup() { trap cleanup EXIT INT TERM main() { + # Set current command for operation logging + export MOLE_CURRENT_COMMAND="uninstall" + log_operation_session_start "uninstall" + local force_rescan=false # Global flags for arg in "$@"; do diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index ccff245..a083433 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -42,7 +42,7 @@ validate_path_for_deletion() { # Check symlink target if path is a symbolic link if [[ -L "$path" ]]; then local link_target - link_target=$(readlink "$path" 2> /dev/null) || { + link_target=$(readlink "$path" 2>/dev/null) || { log_error "Cannot read symlink: $path" return 1 } @@ -52,16 +52,16 @@ validate_path_for_deletion() { if [[ "$link_target" != /* ]]; then local link_dir link_dir=$(dirname "$path") - resolved_target=$(cd "$link_dir" 2> /dev/null && cd "$(dirname "$link_target")" 2> /dev/null && pwd)/$(basename "$link_target") || resolved_target="" + resolved_target=$(cd "$link_dir" 2>/dev/null && cd "$(dirname "$link_target")" 2>/dev/null && pwd)/$(basename "$link_target") || resolved_target="" fi # Validate resolved target against protected paths if [[ -n "$resolved_target" ]]; then case "$resolved_target" in - /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) - log_error "Symlink points to protected system path: $path -> $resolved_target" - return 1 - ;; + /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) + log_error "Symlink points to protected system path: $path -> $resolved_target" + return 1 + ;; esac fi fi @@ -88,48 +88,48 @@ validate_path_for_deletion() { # Allow deletion of coresymbolicationd cache (safe system cache that can be rebuilt) case "$path" in - /System/Library/Caches/com.apple.coresymbolicationd/data | /System/Library/Caches/com.apple.coresymbolicationd/data/*) - return 0 - ;; + /System/Library/Caches/com.apple.coresymbolicationd/data | /System/Library/Caches/com.apple.coresymbolicationd/data/*) + return 0 + ;; esac # Allow known safe paths under /private case "$path" in - /private/tmp | /private/tmp/* | \ - /private/var/tmp | /private/var/tmp/* | \ - /private/var/log | /private/var/log/* | \ - /private/var/folders | /private/var/folders/* | \ - /private/var/db/diagnostics | /private/var/db/diagnostics/* | \ - /private/var/db/DiagnosticPipeline | /private/var/db/DiagnosticPipeline/* | \ - /private/var/db/powerlog | /private/var/db/powerlog/* | \ - /private/var/db/reportmemoryexception | /private/var/db/reportmemoryexception/* | \ - /private/var/db/receipts/*.bom | /private/var/db/receipts/*.plist) - return 0 - ;; + /private/tmp | /private/tmp/* | \ + /private/var/tmp | /private/var/tmp/* | \ + /private/var/log | /private/var/log/* | \ + /private/var/folders | /private/var/folders/* | \ + /private/var/db/diagnostics | /private/var/db/diagnostics/* | \ + /private/var/db/DiagnosticPipeline | /private/var/db/DiagnosticPipeline/* | \ + /private/var/db/powerlog | /private/var/db/powerlog/* | \ + /private/var/db/reportmemoryexception | /private/var/db/reportmemoryexception/* | \ + /private/var/db/receipts/*.bom | /private/var/db/receipts/*.plist) + return 0 + ;; esac # Check path isn't critical system directory case "$path" in - / | /bin | /bin/* | /sbin | /sbin/* | /usr | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /usr/lib | /usr/lib/* | /System | /System/* | /Library/Extensions) - log_error "Path validation failed: critical system directory: $path" - return 1 - ;; - /private) - log_error "Path validation failed: critical system directory: $path" - return 1 - ;; - /etc | /etc/* | /private/etc | /private/etc/*) - log_error "Path validation failed: /etc contains critical system files: $path" - return 1 - ;; - /var | /var/db | /var/db/* | /private/var | /private/var/db | /private/var/db/*) - log_error "Path validation failed: /var/db contains system databases: $path" - return 1 - ;; + / | /bin | /bin/* | /sbin | /sbin/* | /usr | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /usr/lib | /usr/lib/* | /System | /System/* | /Library/Extensions) + log_error "Path validation failed: critical system directory: $path" + return 1 + ;; + /private) + log_error "Path validation failed: critical system directory: $path" + return 1 + ;; + /etc | /etc/* | /private/etc | /private/etc/*) + log_error "Path validation failed: /etc contains critical system files: $path" + return 1 + ;; + /var | /var/db | /var/db/* | /private/var | /private/var/db | /private/var/db/*) + log_error "Path validation failed: /var/db contains system databases: $path" + return 1 + ;; esac # Check if path is protected (keychains, system settings, etc) - if declare -f should_protect_path > /dev/null 2>&1; then + if declare -f should_protect_path >/dev/null 2>&1; then if should_protect_path "$path"; then if [[ "${MO_DEBUG:-0}" == "1" ]]; then log_warning "Path validation: protected path skipped: $path" @@ -172,16 +172,16 @@ safe_remove() { if [[ -e "$path" ]]; then local size_kb - size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0") + size_kb=$(get_path_size_kb "$path" 2>/dev/null || echo "0") if [[ "$size_kb" -gt 0 ]]; then file_size=$(bytes_to_human "$((size_kb * 1024))") fi if [[ -f "$path" || -d "$path" ]] && ! [[ -L "$path" ]]; then local mod_time - mod_time=$(stat -f%m "$path" 2> /dev/null || echo "0") + mod_time=$(stat -f%m "$path" 2>/dev/null || echo "0") local now - now=$(date +%s 2> /dev/null || echo "0") + now=$(date +%s 2>/dev/null || echo "0") if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then file_age=$(((now - mod_time) / 86400)) fi @@ -197,6 +197,18 @@ safe_remove() { debug_log "Removing: $path" + # Calculate size before deletion for logging + local size_kb=0 + local size_human="" + if oplog_enabled; then + if [[ -e "$path" ]]; then + size_kb=$(get_path_size_kb "$path" 2>/dev/null || echo "0") + if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then + size_human=$(bytes_to_human "$((size_kb * 1024))" 2>/dev/null || echo "${size_kb}KB") + fi + fi + fi + # Perform the deletion # Use || to capture the exit code so set -e won't abort on rm failures local error_msg @@ -204,6 +216,8 @@ safe_remove() { error_msg=$(rm -rf "$path" 2>&1) || rm_exit=$? # safe_remove if [[ $rm_exit -eq 0 ]]; then + # Log successful removal + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human" return 0 else # Check if it's a permission error @@ -212,8 +226,10 @@ safe_remove() { MOLE_PERMISSION_DENIED_COUNT=$((MOLE_PERMISSION_DENIED_COUNT + 1)) export MOLE_PERMISSION_DENIED_COUNT debug_log "Permission denied: $path, may need Full Disk Access" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "permission denied" else [[ "$silent" != "true" ]] && log_error "Failed to remove: $path" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "error" fi return 1 fi @@ -249,18 +265,18 @@ safe_sudo_remove() { local file_size="" local file_age="" - if sudo test -e "$path" 2> /dev/null; then + if sudo test -e "$path" 2>/dev/null; then local size_kb - size_kb=$(sudo du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0") + size_kb=$(sudo du -sk "$path" 2>/dev/null | awk '{print $1}' || echo "0") if [[ "$size_kb" -gt 0 ]]; then file_size=$(bytes_to_human "$((size_kb * 1024))") fi - if sudo test -f "$path" 2> /dev/null || sudo test -d "$path" 2> /dev/null; then + if sudo test -f "$path" 2>/dev/null || sudo test -d "$path" 2>/dev/null; then local mod_time - mod_time=$(sudo stat -f%m "$path" 2> /dev/null || echo "0") + mod_time=$(sudo stat -f%m "$path" 2>/dev/null || echo "0") local now - now=$(date +%s 2> /dev/null || echo "0") + now=$(date +%s 2>/dev/null || echo "0") if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then file_age=$(((now - mod_time) / 86400)) fi @@ -276,11 +292,25 @@ safe_sudo_remove() { debug_log "Removing, sudo: $path" + # Calculate size before deletion for logging + local size_kb=0 + local size_human="" + if oplog_enabled; then + if sudo test -e "$path" 2>/dev/null; then + size_kb=$(sudo du -sk "$path" 2>/dev/null | awk '{print $1}' || echo "0") + if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then + size_human=$(bytes_to_human "$((size_kb * 1024))" 2>/dev/null || echo "${size_kb}KB") + fi + fi + fi + # Perform the deletion - if sudo rm -rf "$path" 2> /dev/null; then # SAFE: safe_sudo_remove implementation + if sudo rm -rf "$path" 2>/dev/null; then # SAFE: safe_sudo_remove implementation + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human" return 0 else log_error "Failed to remove, sudo: $path" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "sudo error" return 1 fi } @@ -326,7 +356,7 @@ safe_find_delete() { continue fi safe_remove "$match" true || true - done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) + done < <(command find "$base_dir" "${find_args[@]}" -print0 2>/dev/null || true) return 0 } @@ -339,12 +369,12 @@ safe_sudo_find_delete() { local type_filter="${4:-f}" # Validate base directory (use sudo for permission-restricted dirs) - if ! sudo test -d "$base_dir" 2> /dev/null; then + if ! sudo test -d "$base_dir" 2>/dev/null; then debug_log "Directory does not exist, skipping: $base_dir" return 0 fi - if sudo test -L "$base_dir" 2> /dev/null; then + if sudo test -L "$base_dir" 2>/dev/null; then log_error "Refusing to search symlinked directory: $base_dir" return 1 fi @@ -368,7 +398,7 @@ safe_sudo_find_delete() { continue fi safe_sudo_remove "$match" || true - done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) + done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2>/dev/null || true) return 0 } @@ -388,7 +418,7 @@ get_path_size_kb() { # Use || echo 0 to ensure failure in du (e.g. permission error) doesn't exit script under set -e # Pipefail would normally cause the pipeline to fail if du fails, but || handle catches it. local size - size=$(command du -sk "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true) + size=$(command du -sk "$path" 2>/dev/null | awk 'NR==1 {print $1; exit}' || true) # Ensure size is a valid number (fix for non-numeric du output) if [[ "$size" =~ ^[0-9]+$ ]]; then @@ -409,7 +439,7 @@ calculate_total_size() { size_kb=$(get_path_size_kb "$file") ((total_kb += size_kb)) fi - done <<< "$files" + done <<<"$files" echo "$total_kb" } diff --git a/lib/core/log.sh b/lib/core/log.sh index 8d7f085..2268d84 100644 --- a/lib/core/log.sh +++ b/lib/core/log.sh @@ -23,10 +23,15 @@ fi readonly LOG_FILE="${HOME}/.config/mole/mole.log" readonly DEBUG_LOG_FILE="${HOME}/.config/mole/mole_debug_session.log" -readonly LOG_MAX_SIZE_DEFAULT=1048576 # 1MB +readonly OPERATIONS_LOG_FILE="${HOME}/.config/mole/operations.log" +readonly LOG_MAX_SIZE_DEFAULT=1048576 # 1MB +readonly OPLOG_MAX_SIZE_DEFAULT=5242880 # 5MB # Ensure log directory and file exist with correct ownership ensure_user_file "$LOG_FILE" +if [[ "${MO_NO_OPLOG:-}" != "1" ]]; then + ensure_user_file "$OPERATIONS_LOG_FILE" +fi # ============================================================================ # Log Rotation @@ -40,9 +45,18 @@ rotate_log_once() { local max_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 + mv "$LOG_FILE" "${LOG_FILE}.old" 2>/dev/null || true ensure_user_file "$LOG_FILE" fi + + # Rotate operations log (5MB limit) + if [[ "${MO_NO_OPLOG:-}" != "1" ]]; then + local oplog_max_size="$OPLOG_MAX_SIZE_DEFAULT" + if [[ -f "$OPERATIONS_LOG_FILE" ]] && [[ $(get_file_size "$OPERATIONS_LOG_FILE") -gt "$oplog_max_size" ]]; then + mv "$OPERATIONS_LOG_FILE" "${OPERATIONS_LOG_FILE}.old" 2>/dev/null || true + ensure_user_file "$OPERATIONS_LOG_FILE" + fi + fi } # ============================================================================ @@ -53,9 +67,9 @@ rotate_log_once() { log_info() { echo -e "${BLUE}$1${NC}" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - echo "[$timestamp] INFO: $1" >> "$LOG_FILE" 2> /dev/null || true + echo "[$timestamp] INFO: $1" >>"$LOG_FILE" 2>/dev/null || true if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] INFO: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "[$timestamp] INFO: $1" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } @@ -63,9 +77,9 @@ log_info() { log_success() { echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - echo "[$timestamp] SUCCESS: $1" >> "$LOG_FILE" 2> /dev/null || true + echo "[$timestamp] SUCCESS: $1" >>"$LOG_FILE" 2>/dev/null || true if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] SUCCESS: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "[$timestamp] SUCCESS: $1" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } @@ -73,9 +87,9 @@ log_success() { log_warning() { echo -e "${YELLOW}$1${NC}" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - echo "[$timestamp] WARNING: $1" >> "$LOG_FILE" 2> /dev/null || true + echo "[$timestamp] WARNING: $1" >>"$LOG_FILE" 2>/dev/null || true if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] WARNING: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "[$timestamp] WARNING: $1" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } @@ -83,9 +97,9 @@ log_warning() { log_error() { echo -e "${YELLOW}${ICON_ERROR}${NC} $1" >&2 local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - echo "[$timestamp] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true + echo "[$timestamp] ERROR: $1" >>"$LOG_FILE" 2>/dev/null || true if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] ERROR: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "[$timestamp] ERROR: $1" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } @@ -93,10 +107,84 @@ log_error() { debug_log() { if [[ "${MO_DEBUG:-}" == "1" ]]; then echo -e "${GRAY}[DEBUG]${NC} $*" >&2 - echo "[$(date '+%Y-%m-%d %H:%M:%S')] DEBUG: $*" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "[$(date '+%Y-%m-%d %H:%M:%S')] DEBUG: $*" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } +# ============================================================================ +# Operation Logging (Enabled by default) +# ============================================================================ +# Records all file operations for user troubleshooting +# Disable with MO_NO_OPLOG=1 + +oplog_enabled() { + [[ "${MO_NO_OPLOG:-}" != "1" ]] +} + +# Log an operation to the operations log file +# Usage: log_operation [detail] +# Example: log_operation "clean" "REMOVED" "/path/to/file" "15.2MB" +# Example: log_operation "clean" "SKIPPED" "/path/to/file" "whitelist" +# Example: log_operation "uninstall" "REMOVED" "/Applications/App.app" "150MB" +log_operation() { + # Allow disabling via environment variable + oplog_enabled || return 0 + + local command="${1:-unknown}" # clean/uninstall/optimize/purge + local action="${2:-UNKNOWN}" # REMOVED/SKIPPED/FAILED/REBUILT + local path="${3:-}" + local detail="${4:-}" + + # Skip if no path provided + [[ -z "$path" ]] && return 0 + + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + + local log_line="[$timestamp] [$command] $action $path" + [[ -n "$detail" ]] && log_line+=" ($detail)" + + echo "$log_line" >>"$OPERATIONS_LOG_FILE" 2>/dev/null || true +} + +# Log session start marker +# Usage: log_operation_session_start +log_operation_session_start() { + oplog_enabled || return 0 + + local command="${1:-mole}" + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + + { + echo "" + echo "# ========== $command session started at $timestamp ==========" + } >>"$OPERATIONS_LOG_FILE" 2>/dev/null || true +} + +# Log session end with summary +# Usage: log_operation_session_end +log_operation_session_end() { + oplog_enabled || return 0 + + local command="${1:-mole}" + local items="${2:-0}" + local size="${3:-0}" + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + + local size_human="" + if [[ "$size" =~ ^[0-9]+$ ]] && [[ "$size" -gt 0 ]]; then + size_human=$(bytes_to_human "$((size * 1024))" 2>/dev/null || echo "${size}KB") + else + size_human="0B" + fi + + { + echo "# ========== $command session ended at $timestamp, $items items, $size_human ==========" + } >>"$OPERATIONS_LOG_FILE" 2>/dev/null || true +} + # Enhanced debug logging for operations debug_operation_start() { local operation_name="$1" @@ -112,7 +200,7 @@ debug_operation_start() { echo "" echo "=== $operation_name ===" [[ -n "$operation_desc" ]] && echo "Description: $operation_desc" - } >> "$DEBUG_LOG_FILE" 2> /dev/null || true + } >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } @@ -126,7 +214,7 @@ debug_operation_detail() { echo -e "${GRAY}[DEBUG] $detail_type: $detail_value${NC}" >&2 # Also log to file - echo "$detail_type: $detail_value" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "$detail_type: $detail_value" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } @@ -146,7 +234,7 @@ debug_file_action() { echo -e "${GRAY}[DEBUG] $action: $msg${NC}" >&2 # Also log to file - echo "$action: $msg" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "$action: $msg" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } @@ -158,16 +246,16 @@ debug_risk_level() { if [[ "${MO_DEBUG:-}" == "1" ]]; then local color="$GRAY" case "$risk_level" in - LOW) color="$GREEN" ;; - MEDIUM) color="$YELLOW" ;; - HIGH) color="$RED" ;; + LOW) color="$GREEN" ;; + MEDIUM) color="$YELLOW" ;; + HIGH) color="$RED" ;; esac # Output to stderr with color echo -e "${GRAY}[DEBUG] Risk Level: ${color}${risk_level}${GRAY}, $reason${NC}" >&2 # Also log to file - echo "Risk Level: $risk_level, $reason" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + echo "Risk Level: $risk_level, $reason" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } @@ -179,7 +267,7 @@ log_system_info() { # Reset debug log file for this new session ensure_user_file "$DEBUG_LOG_FILE" - if ! : > "$DEBUG_LOG_FILE" 2> /dev/null; then + if ! : >"$DEBUG_LOG_FILE" 2>/dev/null; then echo -e "${YELLOW}${ICON_WARNING}${NC} Debug log not writable: $DEBUG_LOG_FILE" >&2 fi @@ -192,19 +280,19 @@ log_system_info() { echo "Hostname: $(hostname)" echo "Architecture: $(uname -m)" echo "Kernel: $(uname -r)" - if command -v sw_vers > /dev/null; then + if command -v sw_vers >/dev/null; then echo "macOS: $(sw_vers -productVersion), $(sw_vers -buildVersion)" fi echo "Shell: ${SHELL:-unknown}, ${TERM:-unknown}" # Check sudo status non-interactively - if sudo -n true 2> /dev/null; then + if sudo -n true 2>/dev/null; then echo "Sudo Access: Active" else echo "Sudo Access: Required" fi echo "----------------------------------------------------------------------" - } >> "$DEBUG_LOG_FILE" 2> /dev/null || true + } >>"$DEBUG_LOG_FILE" 2>/dev/null || true # Notification to stderr echo -e "${GRAY}[DEBUG] Debug logging enabled. Session log: $DEBUG_LOG_FILE${NC}" >&2 @@ -216,7 +304,7 @@ log_system_info() { # Run command silently (ignore errors) run_silent() { - "$@" > /dev/null 2>&1 || true + "$@" >/dev/null 2>&1 || true } # Run command with error logging @@ -224,12 +312,12 @@ run_logged() { local cmd="$1" # Log to main file, and also to debug file if enabled if [[ "${MO_DEBUG:-}" == "1" ]]; then - if ! "$@" 2>&1 | tee -a "$LOG_FILE" | tee -a "$DEBUG_LOG_FILE" > /dev/null; then + if ! "$@" 2>&1 | tee -a "$LOG_FILE" | tee -a "$DEBUG_LOG_FILE" >/dev/null; then log_warning "Command failed: $cmd" return 1 fi else - if ! "$@" 2>&1 | tee -a "$LOG_FILE" > /dev/null; then + if ! "$@" 2>&1 | tee -a "$LOG_FILE" >/dev/null; then log_warning "Command failed: $cmd" return 1 fi From 864bade7c61314c850b2b37d6235a60ab3baa9c5 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 26 Jan 2026 15:22:16 +0800 Subject: [PATCH 76/91] fix: improve uninstall guidance and timeouts --- lib/uninstall/batch.sh | 20 +++++++++++++++----- lib/uninstall/brew.sh | 14 +++++++++++++- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 2bbc2df..dba6e55 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -455,22 +455,32 @@ batch_uninstall_applications() { else # Fallback to manual removal if brew fails if [[ "$needs_sudo" == true ]]; then - safe_sudo_remove "$app_path" || reason="remove failed" + if ! safe_sudo_remove "$app_path"; then + reason="brew failed, manual removal failed" + fi else - safe_remove "$app_path" true || reason="remove failed" + if ! safe_remove "$app_path" true; then + reason="brew failed, manual removal failed" + fi fi fi elif [[ "$needs_sudo" == true ]]; then if ! safe_sudo_remove "$app_path"; then local app_owner=$(get_file_owner "$app_path") if [[ -n "$app_owner" && "$app_owner" != "$current_user" && "$app_owner" != "root" ]]; then - reason="owned by $app_owner" + reason="owned by $app_owner, try 'sudo chown $(whoami) \"$app_path\"'" else - reason="permission denied" + reason="permission denied, try 'mole touchid' for passwordless sudo" fi fi else - safe_remove "$app_path" true || reason="remove failed" + if ! safe_remove "$app_path" true; then + if [[ ! -w "$(dirname "$app_path")" ]]; then + reason="parent directory not writable" + else + reason="remove failed, check permissions" + fi + fi fi fi diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index 91f9627..93cf65b 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -183,9 +183,21 @@ brew_uninstall_cask() { local uninstall_ok=false local brew_exit=0 + # Calculate timeout based on app size (large apps need more time) + local timeout=300 # Default 5 minutes + if [[ -n "$app_path" && -d "$app_path" ]]; then + local size_gb=$(($(get_path_size_kb "$app_path") / 1048576)) + if [[ $size_gb -gt 15 ]]; then + timeout=900 # 15 minutes for very large apps (Xcode, Adobe, etc.) + elif [[ $size_gb -gt 5 ]]; then + timeout=600 # 10 minutes for large apps + fi + debug_log "App size: ${size_gb}GB, timeout: ${timeout}s" + fi + # Run with timeout to prevent hangs from problematic cask scripts if HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ - run_with_timeout 300 brew uninstall --cask "$cask_name" 2>&1; then + run_with_timeout "$timeout" brew uninstall --cask "$cask_name" 2>&1; then uninstall_ok=true else brew_exit=$? From 610a4fc0a7d17f3243646e170a44828d99ed3f35 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 26 Jan 2026 15:22:20 +0800 Subject: [PATCH 77/91] chore: simplify optimize maintenance cleanup --- lib/optimize/maintenance.sh | 24 ++++++++++++------------ lib/optimize/tasks.sh | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/optimize/maintenance.sh b/lib/optimize/maintenance.sh index 0cad6b3..09bd3a7 100644 --- a/lib/optimize/maintenance.sh +++ b/lib/optimize/maintenance.sh @@ -17,16 +17,16 @@ fix_broken_preferences() { local filename filename=$(basename "$plist_file") case "$filename" in - com.apple.* | .GlobalPreferences* | loginwindow.plist) - continue - ;; + com.apple.* | .GlobalPreferences* | loginwindow.plist) + continue + ;; esac - plutil -lint "$plist_file" > /dev/null 2>&1 && continue + plutil -lint "$plist_file" >/dev/null 2>&1 && continue - safe_remove "$plist_file" true > /dev/null 2>&1 || true + safe_remove "$plist_file" true >/dev/null 2>&1 || true ((broken_count++)) - done < <(command find "$prefs_dir" -maxdepth 1 -name "*.plist" -type f 2> /dev/null || true) + done < <(command find "$prefs_dir" -maxdepth 1 -name "*.plist" -type f 2>/dev/null || true) # Check ByHost preferences. local byhost_dir="$prefs_dir/ByHost" @@ -37,16 +37,16 @@ fix_broken_preferences() { local filename filename=$(basename "$plist_file") case "$filename" in - com.apple.* | .GlobalPreferences*) - continue - ;; + com.apple.* | .GlobalPreferences*) + continue + ;; esac - plutil -lint "$plist_file" > /dev/null 2>&1 && continue + plutil -lint "$plist_file" >/dev/null 2>&1 && continue - safe_remove "$plist_file" true > /dev/null 2>&1 || true + safe_remove "$plist_file" true >/dev/null 2>&1 || true ((broken_count++)) - done < <(command find "$byhost_dir" -name "*.plist" -type f 2> /dev/null || true) + done < <(command find "$byhost_dir" -name "*.plist" -type f 2>/dev/null || true) fi echo "$broken_count" diff --git a/lib/optimize/tasks.sh b/lib/optimize/tasks.sh index ea0a1fa..c213860 100644 --- a/lib/optimize/tasks.sh +++ b/lib/optimize/tasks.sh @@ -733,7 +733,7 @@ opt_dock_refresh() { if [[ -f "$db_file" ]]; then safe_remove "$db_file" true > /dev/null 2>&1 && refreshed=true fi - done < <(find "$dock_support" -name "*.db" -type f 2> /dev/null || true) + done < <(command find "$dock_support" -name "*.db" -type f 2> /dev/null || true) fi local dock_plist="$HOME/Library/Preferences/com.apple.dock.plist" From 587b127c0109254abb247119f691328432700a5e Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 26 Jan 2026 07:24:38 +0000 Subject: [PATCH 78/91] chore: auto format code --- bin/optimize.sh | 84 +++++++++++++------------- lib/core/file_ops.sh | 114 ++++++++++++++++++------------------ lib/core/log.sh | 58 +++++++++--------- lib/optimize/maintenance.sh | 24 ++++---- lib/uninstall/brew.sh | 6 +- 5 files changed, 143 insertions(+), 143 deletions(-) diff --git a/bin/optimize.sh b/bin/optimize.sh index 3a5e0f1..6dbaaec 100755 --- a/bin/optimize.sh +++ b/bin/optimize.sh @@ -139,12 +139,12 @@ show_optimization_summary() { show_system_health() { local health_json="$1" - local mem_used=$(echo "$health_json" | jq -r '.memory_used_gb // 0' 2>/dev/null || echo "0") - local mem_total=$(echo "$health_json" | jq -r '.memory_total_gb // 0' 2>/dev/null || echo "0") - local disk_used=$(echo "$health_json" | jq -r '.disk_used_gb // 0' 2>/dev/null || echo "0") - local disk_total=$(echo "$health_json" | jq -r '.disk_total_gb // 0' 2>/dev/null || echo "0") - local disk_percent=$(echo "$health_json" | jq -r '.disk_used_percent // 0' 2>/dev/null || echo "0") - local uptime=$(echo "$health_json" | jq -r '.uptime_days // 0' 2>/dev/null || echo "0") + local mem_used=$(echo "$health_json" | jq -r '.memory_used_gb // 0' 2> /dev/null || echo "0") + local mem_total=$(echo "$health_json" | jq -r '.memory_total_gb // 0' 2> /dev/null || echo "0") + local disk_used=$(echo "$health_json" | jq -r '.disk_used_gb // 0' 2> /dev/null || echo "0") + local disk_total=$(echo "$health_json" | jq -r '.disk_total_gb // 0' 2> /dev/null || echo "0") + local disk_percent=$(echo "$health_json" | jq -r '.disk_used_percent // 0' 2> /dev/null || echo "0") + local uptime=$(echo "$health_json" | jq -r '.uptime_days // 0' 2> /dev/null || echo "0") mem_used=${mem_used:-0} mem_total=${mem_total:-0} @@ -159,7 +159,7 @@ show_system_health() { parse_optimizations() { local health_json="$1" - echo "$health_json" | jq -c '.optimizations[]' 2>/dev/null + echo "$health_json" | jq -c '.optimizations[]' 2> /dev/null } announce_action() { @@ -177,12 +177,12 @@ announce_action() { touchid_configured() { local pam_file="/etc/pam.d/sudo" - [[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2>/dev/null + [[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2> /dev/null } touchid_supported() { - if command -v bioutil >/dev/null 2>&1; then - if bioutil -r 2>/dev/null | grep -qi "Touch ID"; then + if command -v bioutil > /dev/null 2>&1; then + if bioutil -r 2> /dev/null | grep -qi "Touch ID"; then return 0 fi fi @@ -272,7 +272,7 @@ ask_for_security_fixes() { echo "" echo -e "${BLUE}SECURITY FIXES${NC}" for entry in "${SECURITY_FIXES[@]}"; do - IFS='|' read -r _ label <<<"$entry" + IFS='|' read -r _ label <<< "$entry" echo -e " ${ICON_LIST} $label" done echo "" @@ -299,7 +299,7 @@ ask_for_security_fixes() { } apply_firewall_fix() { - if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on >/dev/null 2>&1; then + if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} Firewall enabled" FIREWALL_DISABLED=false return 0 @@ -309,7 +309,7 @@ apply_firewall_fix() { } apply_gatekeeper_fix() { - if sudo spctl --master-enable 2>/dev/null; then + if sudo spctl --master-enable 2> /dev/null; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} Gatekeeper enabled" GATEKEEPER_DISABLED=false return 0 @@ -333,17 +333,17 @@ perform_security_fixes() { local applied=0 for entry in "${SECURITY_FIXES[@]}"; do - IFS='|' read -r action _ <<<"$entry" + IFS='|' read -r action _ <<< "$entry" case "$action" in - firewall) - apply_firewall_fix && ((applied++)) - ;; - gatekeeper) - apply_gatekeeper_fix && ((applied++)) - ;; - touchid) - apply_touchid_fix && ((applied++)) - ;; + firewall) + apply_firewall_fix && ((applied++)) + ;; + gatekeeper) + apply_gatekeeper_fix && ((applied++)) + ;; + touchid) + apply_touchid_fix && ((applied++)) + ;; esac done @@ -354,7 +354,7 @@ perform_security_fixes() { } cleanup_all() { - stop_inline_spinner 2>/dev/null || true + stop_inline_spinner 2> /dev/null || true stop_sudo_session cleanup_temp_files # Log session end @@ -373,16 +373,16 @@ main() { local health_json for arg in "$@"; do case "$arg" in - "--debug") - export MO_DEBUG=1 - ;; - "--dry-run") - export MOLE_DRY_RUN=1 - ;; - "--whitelist") - manage_whitelist "optimize" - exit 0 - ;; + "--debug") + export MO_DEBUG=1 + ;; + "--dry-run") + export MOLE_DRY_RUN=1 + ;; + "--whitelist") + manage_whitelist "optimize" + exit 0 + ;; esac done @@ -401,13 +401,13 @@ main() { echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No files will be modified\n" fi - if ! command -v jq >/dev/null 2>&1; then + if ! command -v jq > /dev/null 2>&1; then echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: jq" echo -e "${GRAY}Install with: ${GREEN}brew install jq${NC}" exit 1 fi - if ! command -v bc >/dev/null 2>&1; then + if ! command -v bc > /dev/null 2>&1; then echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: bc" echo -e "${GRAY}Install with: ${GREEN}brew install bc${NC}" exit 1 @@ -417,7 +417,7 @@ main() { start_inline_spinner "Collecting system info..." fi - if ! health_json=$(generate_health_json 2>/dev/null); then + if ! health_json=$(generate_health_json 2> /dev/null); then if [[ -t 1 ]]; then stop_inline_spinner fi @@ -426,7 +426,7 @@ main() { exit 1 fi - if ! echo "$health_json" | jq empty 2>/dev/null; then + if ! echo "$health_json" | jq empty 2> /dev/null; then if [[ -t 1 ]]; then stop_inline_spinner fi @@ -458,7 +458,7 @@ main() { local -a confirm_items=() local opts_file opts_file=$(mktemp_file) - parse_optimizations "$health_json" >"$opts_file" + parse_optimizations "$health_json" > "$opts_file" while IFS= read -r opt_json; do [[ -z "$opt_json" ]] && continue @@ -476,7 +476,7 @@ main() { else confirm_items+=("$item") fi - done <"$opts_file" + done < "$opts_file" echo "" if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then @@ -486,7 +486,7 @@ main() { export FIRST_ACTION=true if [[ ${#safe_items[@]} -gt 0 ]]; then for item in "${safe_items[@]}"; do - IFS='|' read -r name desc action path <<<"$item" + IFS='|' read -r name desc action path <<< "$item" announce_action "$name" "$desc" "safe" execute_optimization "$action" "$path" done @@ -494,7 +494,7 @@ main() { if [[ ${#confirm_items[@]} -gt 0 ]]; then for item in "${confirm_items[@]}"; do - IFS='|' read -r name desc action path <<<"$item" + IFS='|' read -r name desc action path <<< "$item" announce_action "$name" "$desc" "confirm" execute_optimization "$action" "$path" done diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index a083433..33a9126 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -42,7 +42,7 @@ validate_path_for_deletion() { # Check symlink target if path is a symbolic link if [[ -L "$path" ]]; then local link_target - link_target=$(readlink "$path" 2>/dev/null) || { + link_target=$(readlink "$path" 2> /dev/null) || { log_error "Cannot read symlink: $path" return 1 } @@ -52,16 +52,16 @@ validate_path_for_deletion() { if [[ "$link_target" != /* ]]; then local link_dir link_dir=$(dirname "$path") - resolved_target=$(cd "$link_dir" 2>/dev/null && cd "$(dirname "$link_target")" 2>/dev/null && pwd)/$(basename "$link_target") || resolved_target="" + resolved_target=$(cd "$link_dir" 2> /dev/null && cd "$(dirname "$link_target")" 2> /dev/null && pwd)/$(basename "$link_target") || resolved_target="" fi # Validate resolved target against protected paths if [[ -n "$resolved_target" ]]; then case "$resolved_target" in - /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) - log_error "Symlink points to protected system path: $path -> $resolved_target" - return 1 - ;; + /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) + log_error "Symlink points to protected system path: $path -> $resolved_target" + return 1 + ;; esac fi fi @@ -88,48 +88,48 @@ validate_path_for_deletion() { # Allow deletion of coresymbolicationd cache (safe system cache that can be rebuilt) case "$path" in - /System/Library/Caches/com.apple.coresymbolicationd/data | /System/Library/Caches/com.apple.coresymbolicationd/data/*) - return 0 - ;; + /System/Library/Caches/com.apple.coresymbolicationd/data | /System/Library/Caches/com.apple.coresymbolicationd/data/*) + return 0 + ;; esac # Allow known safe paths under /private case "$path" in - /private/tmp | /private/tmp/* | \ - /private/var/tmp | /private/var/tmp/* | \ - /private/var/log | /private/var/log/* | \ - /private/var/folders | /private/var/folders/* | \ - /private/var/db/diagnostics | /private/var/db/diagnostics/* | \ - /private/var/db/DiagnosticPipeline | /private/var/db/DiagnosticPipeline/* | \ - /private/var/db/powerlog | /private/var/db/powerlog/* | \ - /private/var/db/reportmemoryexception | /private/var/db/reportmemoryexception/* | \ - /private/var/db/receipts/*.bom | /private/var/db/receipts/*.plist) - return 0 - ;; + /private/tmp | /private/tmp/* | \ + /private/var/tmp | /private/var/tmp/* | \ + /private/var/log | /private/var/log/* | \ + /private/var/folders | /private/var/folders/* | \ + /private/var/db/diagnostics | /private/var/db/diagnostics/* | \ + /private/var/db/DiagnosticPipeline | /private/var/db/DiagnosticPipeline/* | \ + /private/var/db/powerlog | /private/var/db/powerlog/* | \ + /private/var/db/reportmemoryexception | /private/var/db/reportmemoryexception/* | \ + /private/var/db/receipts/*.bom | /private/var/db/receipts/*.plist) + return 0 + ;; esac # Check path isn't critical system directory case "$path" in - / | /bin | /bin/* | /sbin | /sbin/* | /usr | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /usr/lib | /usr/lib/* | /System | /System/* | /Library/Extensions) - log_error "Path validation failed: critical system directory: $path" - return 1 - ;; - /private) - log_error "Path validation failed: critical system directory: $path" - return 1 - ;; - /etc | /etc/* | /private/etc | /private/etc/*) - log_error "Path validation failed: /etc contains critical system files: $path" - return 1 - ;; - /var | /var/db | /var/db/* | /private/var | /private/var/db | /private/var/db/*) - log_error "Path validation failed: /var/db contains system databases: $path" - return 1 - ;; + / | /bin | /bin/* | /sbin | /sbin/* | /usr | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /usr/lib | /usr/lib/* | /System | /System/* | /Library/Extensions) + log_error "Path validation failed: critical system directory: $path" + return 1 + ;; + /private) + log_error "Path validation failed: critical system directory: $path" + return 1 + ;; + /etc | /etc/* | /private/etc | /private/etc/*) + log_error "Path validation failed: /etc contains critical system files: $path" + return 1 + ;; + /var | /var/db | /var/db/* | /private/var | /private/var/db | /private/var/db/*) + log_error "Path validation failed: /var/db contains system databases: $path" + return 1 + ;; esac # Check if path is protected (keychains, system settings, etc) - if declare -f should_protect_path >/dev/null 2>&1; then + if declare -f should_protect_path > /dev/null 2>&1; then if should_protect_path "$path"; then if [[ "${MO_DEBUG:-0}" == "1" ]]; then log_warning "Path validation: protected path skipped: $path" @@ -172,16 +172,16 @@ safe_remove() { if [[ -e "$path" ]]; then local size_kb - size_kb=$(get_path_size_kb "$path" 2>/dev/null || echo "0") + size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0") if [[ "$size_kb" -gt 0 ]]; then file_size=$(bytes_to_human "$((size_kb * 1024))") fi if [[ -f "$path" || -d "$path" ]] && ! [[ -L "$path" ]]; then local mod_time - mod_time=$(stat -f%m "$path" 2>/dev/null || echo "0") + mod_time=$(stat -f%m "$path" 2> /dev/null || echo "0") local now - now=$(date +%s 2>/dev/null || echo "0") + now=$(date +%s 2> /dev/null || echo "0") if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then file_age=$(((now - mod_time) / 86400)) fi @@ -202,9 +202,9 @@ safe_remove() { local size_human="" if oplog_enabled; then if [[ -e "$path" ]]; then - size_kb=$(get_path_size_kb "$path" 2>/dev/null || echo "0") + size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0") if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then - size_human=$(bytes_to_human "$((size_kb * 1024))" 2>/dev/null || echo "${size_kb}KB") + size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB") fi fi fi @@ -265,18 +265,18 @@ safe_sudo_remove() { local file_size="" local file_age="" - if sudo test -e "$path" 2>/dev/null; then + if sudo test -e "$path" 2> /dev/null; then local size_kb - size_kb=$(sudo du -sk "$path" 2>/dev/null | awk '{print $1}' || echo "0") + size_kb=$(sudo du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0") if [[ "$size_kb" -gt 0 ]]; then file_size=$(bytes_to_human "$((size_kb * 1024))") fi - if sudo test -f "$path" 2>/dev/null || sudo test -d "$path" 2>/dev/null; then + if sudo test -f "$path" 2> /dev/null || sudo test -d "$path" 2> /dev/null; then local mod_time - mod_time=$(sudo stat -f%m "$path" 2>/dev/null || echo "0") + mod_time=$(sudo stat -f%m "$path" 2> /dev/null || echo "0") local now - now=$(date +%s 2>/dev/null || echo "0") + now=$(date +%s 2> /dev/null || echo "0") if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then file_age=$(((now - mod_time) / 86400)) fi @@ -296,16 +296,16 @@ safe_sudo_remove() { local size_kb=0 local size_human="" if oplog_enabled; then - if sudo test -e "$path" 2>/dev/null; then - size_kb=$(sudo du -sk "$path" 2>/dev/null | awk '{print $1}' || echo "0") + if sudo test -e "$path" 2> /dev/null; then + size_kb=$(sudo du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0") if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then - size_human=$(bytes_to_human "$((size_kb * 1024))" 2>/dev/null || echo "${size_kb}KB") + size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB") fi fi fi # Perform the deletion - if sudo rm -rf "$path" 2>/dev/null; then # SAFE: safe_sudo_remove implementation + if sudo rm -rf "$path" 2> /dev/null; then # SAFE: safe_sudo_remove implementation log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human" return 0 else @@ -356,7 +356,7 @@ safe_find_delete() { continue fi safe_remove "$match" true || true - done < <(command find "$base_dir" "${find_args[@]}" -print0 2>/dev/null || true) + done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) return 0 } @@ -369,12 +369,12 @@ safe_sudo_find_delete() { local type_filter="${4:-f}" # Validate base directory (use sudo for permission-restricted dirs) - if ! sudo test -d "$base_dir" 2>/dev/null; then + if ! sudo test -d "$base_dir" 2> /dev/null; then debug_log "Directory does not exist, skipping: $base_dir" return 0 fi - if sudo test -L "$base_dir" 2>/dev/null; then + if sudo test -L "$base_dir" 2> /dev/null; then log_error "Refusing to search symlinked directory: $base_dir" return 1 fi @@ -398,7 +398,7 @@ safe_sudo_find_delete() { continue fi safe_sudo_remove "$match" || true - done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2>/dev/null || true) + done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) return 0 } @@ -418,7 +418,7 @@ get_path_size_kb() { # Use || echo 0 to ensure failure in du (e.g. permission error) doesn't exit script under set -e # Pipefail would normally cause the pipeline to fail if du fails, but || handle catches it. local size - size=$(command du -sk "$path" 2>/dev/null | awk 'NR==1 {print $1; exit}' || true) + size=$(command du -sk "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true) # Ensure size is a valid number (fix for non-numeric du output) if [[ "$size" =~ ^[0-9]+$ ]]; then @@ -439,7 +439,7 @@ calculate_total_size() { size_kb=$(get_path_size_kb "$file") ((total_kb += size_kb)) fi - done <<<"$files" + done <<< "$files" echo "$total_kb" } diff --git a/lib/core/log.sh b/lib/core/log.sh index 2268d84..cc933cd 100644 --- a/lib/core/log.sh +++ b/lib/core/log.sh @@ -45,7 +45,7 @@ rotate_log_once() { local max_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 + mv "$LOG_FILE" "${LOG_FILE}.old" 2> /dev/null || true ensure_user_file "$LOG_FILE" fi @@ -53,7 +53,7 @@ rotate_log_once() { if [[ "${MO_NO_OPLOG:-}" != "1" ]]; then local oplog_max_size="$OPLOG_MAX_SIZE_DEFAULT" if [[ -f "$OPERATIONS_LOG_FILE" ]] && [[ $(get_file_size "$OPERATIONS_LOG_FILE") -gt "$oplog_max_size" ]]; then - mv "$OPERATIONS_LOG_FILE" "${OPERATIONS_LOG_FILE}.old" 2>/dev/null || true + mv "$OPERATIONS_LOG_FILE" "${OPERATIONS_LOG_FILE}.old" 2> /dev/null || true ensure_user_file "$OPERATIONS_LOG_FILE" fi fi @@ -67,9 +67,9 @@ rotate_log_once() { log_info() { echo -e "${BLUE}$1${NC}" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - echo "[$timestamp] INFO: $1" >>"$LOG_FILE" 2>/dev/null || true + echo "[$timestamp] INFO: $1" >> "$LOG_FILE" 2> /dev/null || true if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] INFO: $1" >>"$DEBUG_LOG_FILE" 2>/dev/null || true + echo "[$timestamp] INFO: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true fi } @@ -77,9 +77,9 @@ log_info() { log_success() { echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - echo "[$timestamp] SUCCESS: $1" >>"$LOG_FILE" 2>/dev/null || true + echo "[$timestamp] SUCCESS: $1" >> "$LOG_FILE" 2> /dev/null || true if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] SUCCESS: $1" >>"$DEBUG_LOG_FILE" 2>/dev/null || true + echo "[$timestamp] SUCCESS: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true fi } @@ -87,9 +87,9 @@ log_success() { log_warning() { echo -e "${YELLOW}$1${NC}" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - echo "[$timestamp] WARNING: $1" >>"$LOG_FILE" 2>/dev/null || true + echo "[$timestamp] WARNING: $1" >> "$LOG_FILE" 2> /dev/null || true if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] WARNING: $1" >>"$DEBUG_LOG_FILE" 2>/dev/null || true + echo "[$timestamp] WARNING: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true fi } @@ -97,9 +97,9 @@ log_warning() { log_error() { echo -e "${YELLOW}${ICON_ERROR}${NC} $1" >&2 local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - echo "[$timestamp] ERROR: $1" >>"$LOG_FILE" 2>/dev/null || true + echo "[$timestamp] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] ERROR: $1" >>"$DEBUG_LOG_FILE" 2>/dev/null || true + echo "[$timestamp] ERROR: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true fi } @@ -107,7 +107,7 @@ log_error() { debug_log() { if [[ "${MO_DEBUG:-}" == "1" ]]; then echo -e "${GRAY}[DEBUG]${NC} $*" >&2 - echo "[$(date '+%Y-%m-%d %H:%M:%S')] DEBUG: $*" >>"$DEBUG_LOG_FILE" 2>/dev/null || true + echo "[$(date '+%Y-%m-%d %H:%M:%S')] DEBUG: $*" >> "$DEBUG_LOG_FILE" 2> /dev/null || true fi } @@ -144,7 +144,7 @@ log_operation() { local log_line="[$timestamp] [$command] $action $path" [[ -n "$detail" ]] && log_line+=" ($detail)" - echo "$log_line" >>"$OPERATIONS_LOG_FILE" 2>/dev/null || true + echo "$log_line" >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true } # Log session start marker @@ -159,7 +159,7 @@ log_operation_session_start() { { echo "" echo "# ========== $command session started at $timestamp ==========" - } >>"$OPERATIONS_LOG_FILE" 2>/dev/null || true + } >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true } # Log session end with summary @@ -175,14 +175,14 @@ log_operation_session_end() { local size_human="" if [[ "$size" =~ ^[0-9]+$ ]] && [[ "$size" -gt 0 ]]; then - size_human=$(bytes_to_human "$((size * 1024))" 2>/dev/null || echo "${size}KB") + size_human=$(bytes_to_human "$((size * 1024))" 2> /dev/null || echo "${size}KB") else size_human="0B" fi { echo "# ========== $command session ended at $timestamp, $items items, $size_human ==========" - } >>"$OPERATIONS_LOG_FILE" 2>/dev/null || true + } >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true } # Enhanced debug logging for operations @@ -200,7 +200,7 @@ debug_operation_start() { echo "" echo "=== $operation_name ===" [[ -n "$operation_desc" ]] && echo "Description: $operation_desc" - } >>"$DEBUG_LOG_FILE" 2>/dev/null || true + } >> "$DEBUG_LOG_FILE" 2> /dev/null || true fi } @@ -214,7 +214,7 @@ debug_operation_detail() { echo -e "${GRAY}[DEBUG] $detail_type: $detail_value${NC}" >&2 # Also log to file - echo "$detail_type: $detail_value" >>"$DEBUG_LOG_FILE" 2>/dev/null || true + echo "$detail_type: $detail_value" >> "$DEBUG_LOG_FILE" 2> /dev/null || true fi } @@ -234,7 +234,7 @@ debug_file_action() { echo -e "${GRAY}[DEBUG] $action: $msg${NC}" >&2 # Also log to file - echo "$action: $msg" >>"$DEBUG_LOG_FILE" 2>/dev/null || true + echo "$action: $msg" >> "$DEBUG_LOG_FILE" 2> /dev/null || true fi } @@ -246,16 +246,16 @@ debug_risk_level() { if [[ "${MO_DEBUG:-}" == "1" ]]; then local color="$GRAY" case "$risk_level" in - LOW) color="$GREEN" ;; - MEDIUM) color="$YELLOW" ;; - HIGH) color="$RED" ;; + LOW) color="$GREEN" ;; + MEDIUM) color="$YELLOW" ;; + HIGH) color="$RED" ;; esac # Output to stderr with color echo -e "${GRAY}[DEBUG] Risk Level: ${color}${risk_level}${GRAY}, $reason${NC}" >&2 # Also log to file - echo "Risk Level: $risk_level, $reason" >>"$DEBUG_LOG_FILE" 2>/dev/null || true + echo "Risk Level: $risk_level, $reason" >> "$DEBUG_LOG_FILE" 2> /dev/null || true fi } @@ -267,7 +267,7 @@ log_system_info() { # Reset debug log file for this new session ensure_user_file "$DEBUG_LOG_FILE" - if ! : >"$DEBUG_LOG_FILE" 2>/dev/null; then + if ! : > "$DEBUG_LOG_FILE" 2> /dev/null; then echo -e "${YELLOW}${ICON_WARNING}${NC} Debug log not writable: $DEBUG_LOG_FILE" >&2 fi @@ -280,19 +280,19 @@ log_system_info() { echo "Hostname: $(hostname)" echo "Architecture: $(uname -m)" echo "Kernel: $(uname -r)" - if command -v sw_vers >/dev/null; then + if command -v sw_vers > /dev/null; then echo "macOS: $(sw_vers -productVersion), $(sw_vers -buildVersion)" fi echo "Shell: ${SHELL:-unknown}, ${TERM:-unknown}" # Check sudo status non-interactively - if sudo -n true 2>/dev/null; then + if sudo -n true 2> /dev/null; then echo "Sudo Access: Active" else echo "Sudo Access: Required" fi echo "----------------------------------------------------------------------" - } >>"$DEBUG_LOG_FILE" 2>/dev/null || true + } >> "$DEBUG_LOG_FILE" 2> /dev/null || true # Notification to stderr echo -e "${GRAY}[DEBUG] Debug logging enabled. Session log: $DEBUG_LOG_FILE${NC}" >&2 @@ -304,7 +304,7 @@ log_system_info() { # Run command silently (ignore errors) run_silent() { - "$@" >/dev/null 2>&1 || true + "$@" > /dev/null 2>&1 || true } # Run command with error logging @@ -312,12 +312,12 @@ run_logged() { local cmd="$1" # Log to main file, and also to debug file if enabled if [[ "${MO_DEBUG:-}" == "1" ]]; then - if ! "$@" 2>&1 | tee -a "$LOG_FILE" | tee -a "$DEBUG_LOG_FILE" >/dev/null; then + if ! "$@" 2>&1 | tee -a "$LOG_FILE" | tee -a "$DEBUG_LOG_FILE" > /dev/null; then log_warning "Command failed: $cmd" return 1 fi else - if ! "$@" 2>&1 | tee -a "$LOG_FILE" >/dev/null; then + if ! "$@" 2>&1 | tee -a "$LOG_FILE" > /dev/null; then log_warning "Command failed: $cmd" return 1 fi diff --git a/lib/optimize/maintenance.sh b/lib/optimize/maintenance.sh index 09bd3a7..0cad6b3 100644 --- a/lib/optimize/maintenance.sh +++ b/lib/optimize/maintenance.sh @@ -17,16 +17,16 @@ fix_broken_preferences() { local filename filename=$(basename "$plist_file") case "$filename" in - com.apple.* | .GlobalPreferences* | loginwindow.plist) - continue - ;; + com.apple.* | .GlobalPreferences* | loginwindow.plist) + continue + ;; esac - plutil -lint "$plist_file" >/dev/null 2>&1 && continue + plutil -lint "$plist_file" > /dev/null 2>&1 && continue - safe_remove "$plist_file" true >/dev/null 2>&1 || true + safe_remove "$plist_file" true > /dev/null 2>&1 || true ((broken_count++)) - done < <(command find "$prefs_dir" -maxdepth 1 -name "*.plist" -type f 2>/dev/null || true) + done < <(command find "$prefs_dir" -maxdepth 1 -name "*.plist" -type f 2> /dev/null || true) # Check ByHost preferences. local byhost_dir="$prefs_dir/ByHost" @@ -37,16 +37,16 @@ fix_broken_preferences() { local filename filename=$(basename "$plist_file") case "$filename" in - com.apple.* | .GlobalPreferences*) - continue - ;; + com.apple.* | .GlobalPreferences*) + continue + ;; esac - plutil -lint "$plist_file" >/dev/null 2>&1 && continue + plutil -lint "$plist_file" > /dev/null 2>&1 && continue - safe_remove "$plist_file" true >/dev/null 2>&1 || true + safe_remove "$plist_file" true > /dev/null 2>&1 || true ((broken_count++)) - done < <(command find "$byhost_dir" -name "*.plist" -type f 2>/dev/null || true) + done < <(command find "$byhost_dir" -name "*.plist" -type f 2> /dev/null || true) fi echo "$broken_count" diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index 93cf65b..5e2f13f 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -184,13 +184,13 @@ brew_uninstall_cask() { local brew_exit=0 # Calculate timeout based on app size (large apps need more time) - local timeout=300 # Default 5 minutes + local timeout=300 # Default 5 minutes if [[ -n "$app_path" && -d "$app_path" ]]; then local size_gb=$(($(get_path_size_kb "$app_path") / 1048576)) if [[ $size_gb -gt 15 ]]; then - timeout=900 # 15 minutes for very large apps (Xcode, Adobe, etc.) + timeout=900 # 15 minutes for very large apps (Xcode, Adobe, etc.) elif [[ $size_gb -gt 5 ]]; then - timeout=600 # 10 minutes for large apps + timeout=600 # 10 minutes for large apps fi debug_log "App size: ${size_gb}GB, timeout: ${timeout}s" fi From 981c79cfd1b3ce9d0dd58f0bef2c971458c4e4c4 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 26 Jan 2026 15:31:30 +0800 Subject: [PATCH 79/91] fix: clear cleaning spinner after cleanup --- bin/clean.sh | 94 +++++++++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 45 deletions(-) diff --git a/bin/clean.sh b/bin/clean.sh index 1a182fe..b8858d4 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -69,10 +69,10 @@ if [[ -f "$HOME/.config/mole/whitelist" ]]; then fi case "$line" in - / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /etc | /etc/* | /var/db | /var/db/*) - WHITELIST_WARNINGS+=("Protected system path: $line") - continue - ;; + / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /etc | /etc/* | /var/db | /var/db/*) + WHITELIST_WARNINGS+=("Protected system path: $line") + continue + ;; esac duplicate="false" @@ -86,7 +86,7 @@ if [[ -f "$HOME/.config/mole/whitelist" ]]; then fi [[ "$duplicate" == "true" ]] && continue WHITELIST_PATTERNS+=("$line") - done < "$HOME/.config/mole/whitelist" + done <"$HOME/.config/mole/whitelist" else WHITELIST_PATTERNS=("${DEFAULT_WHITELIST_PATTERNS[@]}") fi @@ -140,7 +140,7 @@ cleanup() { fi CLEANUP_DONE=true - stop_inline_spinner 2> /dev/null || true + stop_inline_spinner 2>/dev/null || true if [[ -t 1 ]]; then printf "\r\033[K" >&2 || true @@ -166,8 +166,8 @@ start_section() { if [[ "$DRY_RUN" == "true" ]]; then ensure_user_file "$EXPORT_LIST_FILE" - echo "" >> "$EXPORT_LIST_FILE" - echo "=== $1 ===" >> "$EXPORT_LIST_FILE" + echo "" >>"$EXPORT_LIST_FILE" + echo "=== $1 ===" >>"$EXPORT_LIST_FILE" fi } @@ -220,7 +220,7 @@ normalize_paths_for_cleanup() { done fi [[ "$is_child" == "true" ]] || result_paths+=("$path") - done <<< "$sorted_paths" + done <<<"$sorted_paths" if [[ ${#result_paths[@]} -gt 0 ]]; then printf '%s\n' "${result_paths[@]}" @@ -232,9 +232,9 @@ get_cleanup_path_size_kb() { local path="$1" if [[ -f "$path" && ! -L "$path" ]]; then - if command -v stat > /dev/null 2>&1; then + if command -v stat >/dev/null 2>&1; then local bytes - bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") + bytes=$(stat -f%z "$path" 2>/dev/null || echo "0") if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then echo $(((bytes + 1023) / 1024)) return 0 @@ -243,9 +243,9 @@ get_cleanup_path_size_kb() { fi if [[ -L "$path" ]]; then - if command -v stat > /dev/null 2>&1; then + if command -v stat >/dev/null 2>&1; then local bytes - bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") + bytes=$(stat -f%z "$path" 2>/dev/null || echo "0") if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then echo $(((bytes + 1023) / 1024)) else @@ -433,6 +433,8 @@ safe_clean() { if [[ -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning items..."; fi fi + local cleaning_spinner_started=false + # For larger batches, precompute sizes in parallel for better UX/stat accuracy. if [[ ${#existing_paths[@]} -gt 3 ]]; then local temp_dir @@ -463,9 +465,9 @@ safe_clean() { [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 if [[ "$size" -gt 0 ]]; then - echo "$size 1" > "$temp_dir/result_${idx}" + echo "$size 1" >"$temp_dir/result_${idx}" else - echo "0 0" > "$temp_dir/result_${idx}" + echo "0 0" >"$temp_dir/result_${idx}" fi ((idx++)) @@ -490,17 +492,17 @@ safe_clean() { [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 local tmp_file="$temp_dir/result_${idx}.$$" if [[ "$size" -gt 0 ]]; then - echo "$size 1" > "$tmp_file" + echo "$size 1" >"$tmp_file" else - echo "0 0" > "$tmp_file" + echo "0 0" >"$tmp_file" fi - mv "$tmp_file" "$temp_dir/result_${idx}" 2> /dev/null || true + mv "$tmp_file" "$temp_dir/result_${idx}" 2>/dev/null || true ) & pids+=($!) ((idx++)) if ((${#pids[@]} >= MOLE_MAX_PARALLEL_JOBS)); then - wait "${pids[0]}" 2> /dev/null || true + wait "${pids[0]}" 2>/dev/null || true pids=("${pids[@]:1}") ((completed++)) @@ -513,7 +515,7 @@ safe_clean() { if [[ ${#pids[@]} -gt 0 ]]; then for pid in "${pids[@]}"; do - wait "$pid" 2> /dev/null || true + wait "$pid" 2>/dev/null || true ((completed++)) if [[ "$show_spinner" == "true" && -t 1 ]]; then @@ -527,17 +529,18 @@ safe_clean() { # Start spinner for cleaning phase if [[ "$DRY_RUN" != "true" && ${#existing_paths[@]} -gt 0 && -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Cleaning..." + cleaning_spinner_started=true fi idx=0 if [[ ${#existing_paths[@]} -gt 0 ]]; then for path in "${existing_paths[@]}"; do local result_file="$temp_dir/result_${idx}" if [[ -f "$result_file" ]]; then - read -r size count < "$result_file" 2> /dev/null || true + read -r size count <"$result_file" 2>/dev/null || true local removed=0 if [[ "$DRY_RUN" != "true" ]]; then if [[ -L "$path" ]]; then - rm "$path" 2> /dev/null && removed=1 + rm "$path" 2>/dev/null && removed=1 else if safe_remove "$path" true; then removed=1 @@ -567,6 +570,7 @@ safe_clean() { # Start spinner for cleaning phase (small batch) if [[ "$DRY_RUN" != "true" && ${#existing_paths[@]} -gt 0 && -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Cleaning..." + cleaning_spinner_started=true fi local idx=0 if [[ ${#existing_paths[@]} -gt 0 ]]; then @@ -578,7 +582,7 @@ safe_clean() { local removed=0 if [[ "$DRY_RUN" != "true" ]]; then if [[ -L "$path" ]]; then - rm "$path" 2> /dev/null && removed=1 + rm "$path" 2>/dev/null && removed=1 else if safe_remove "$path" true; then removed=1 @@ -604,7 +608,7 @@ safe_clean() { fi fi - if [[ "$show_spinner" == "true" ]]; then + if [[ "$show_spinner" == "true" || "$cleaning_spinner_started" == "true" ]]; then stop_section_spinner fi @@ -639,9 +643,9 @@ safe_clean() { local size=0 if [[ -n "${temp_dir:-}" && -f "$temp_dir/result_${idx}" ]]; then - read -r size count < "$temp_dir/result_${idx}" 2> /dev/null || true + read -r size count <"$temp_dir/result_${idx}" 2>/dev/null || true else - size=$(get_cleanup_path_size_kb "$path" 2> /dev/null || echo "0") + size=$(get_cleanup_path_size_kb "$path" 2>/dev/null || echo "0") fi [[ "$size" == "0" || -z "$size" ]] && { @@ -649,7 +653,7 @@ safe_clean() { continue } - echo "$(dirname "$path")|$size|$path" >> "$paths_temp" + echo "$(dirname "$path")|$size|$path" >>"$paths_temp" ((idx++)) done fi @@ -680,9 +684,9 @@ safe_clean() { ' | while IFS='|' read -r display_path total_size child_count; do local size_human=$(bytes_to_human "$((total_size * 1024))") if [[ $child_count -gt 1 ]]; then - echo "$display_path # $size_human, $child_count items" >> "$EXPORT_LIST_FILE" + echo "$display_path # $size_human, $child_count items" >>"$EXPORT_LIST_FILE" else - echo "$display_path # $size_human" >> "$EXPORT_LIST_FILE" + echo "$display_path # $size_human" >>"$EXPORT_LIST_FILE" fi done @@ -722,7 +726,7 @@ start_cleanup() { SYSTEM_CLEAN=false ensure_user_file "$EXPORT_LIST_FILE" - cat > "$EXPORT_LIST_FILE" << EOF + cat >"$EXPORT_LIST_FILE" < /dev/null; then + if sudo -n true 2>/dev/null; then SYSTEM_CLEAN=true echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access already available" echo "" @@ -778,7 +782,7 @@ EOF else echo "" echo "Running in non-interactive mode" - if sudo -n true 2> /dev/null; then + if sudo -n true 2>/dev/null; then SYSTEM_CLEAN=true echo " ${ICON_LIST} System-level cleanup enabled, sudo session active" else @@ -1029,7 +1033,7 @@ perform_cleanup() { echo "# Potential cleanup: ${freed_gb}GB" echo "# Items: $files_cleaned" echo "# Categories: $total_items" - } >> "$EXPORT_LIST_FILE" + } >>"$EXPORT_LIST_FILE" summary_details+=("Detailed file list: ${GRAY}$EXPORT_LIST_FILE${NC}") summary_details+=("Use ${GRAY}mo clean --whitelist${NC} to add protection rules") @@ -1081,18 +1085,18 @@ perform_cleanup() { main() { for arg in "$@"; do case "$arg" in - "--debug") - export MO_DEBUG=1 - ;; - "--dry-run" | "-n") - DRY_RUN=true - export MOLE_DRY_RUN=1 - ;; - "--whitelist") - source "$SCRIPT_DIR/../lib/manage/whitelist.sh" - manage_whitelist "clean" - exit 0 - ;; + "--debug") + export MO_DEBUG=1 + ;; + "--dry-run" | "-n") + DRY_RUN=true + export MOLE_DRY_RUN=1 + ;; + "--whitelist") + source "$SCRIPT_DIR/../lib/manage/whitelist.sh" + manage_whitelist "clean" + exit 0 + ;; esac done From f272d29490dadb7b8886adc24d1ee126fa675d46 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 26 Jan 2026 07:32:44 +0000 Subject: [PATCH 80/91] chore: auto format code --- bin/clean.sh | 88 ++++++++++++++++++++++++++-------------------------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/bin/clean.sh b/bin/clean.sh index b8858d4..c096cb0 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -69,10 +69,10 @@ if [[ -f "$HOME/.config/mole/whitelist" ]]; then fi case "$line" in - / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /etc | /etc/* | /var/db | /var/db/*) - WHITELIST_WARNINGS+=("Protected system path: $line") - continue - ;; + / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /etc | /etc/* | /var/db | /var/db/*) + WHITELIST_WARNINGS+=("Protected system path: $line") + continue + ;; esac duplicate="false" @@ -86,7 +86,7 @@ if [[ -f "$HOME/.config/mole/whitelist" ]]; then fi [[ "$duplicate" == "true" ]] && continue WHITELIST_PATTERNS+=("$line") - done <"$HOME/.config/mole/whitelist" + done < "$HOME/.config/mole/whitelist" else WHITELIST_PATTERNS=("${DEFAULT_WHITELIST_PATTERNS[@]}") fi @@ -140,7 +140,7 @@ cleanup() { fi CLEANUP_DONE=true - stop_inline_spinner 2>/dev/null || true + stop_inline_spinner 2> /dev/null || true if [[ -t 1 ]]; then printf "\r\033[K" >&2 || true @@ -166,8 +166,8 @@ start_section() { if [[ "$DRY_RUN" == "true" ]]; then ensure_user_file "$EXPORT_LIST_FILE" - echo "" >>"$EXPORT_LIST_FILE" - echo "=== $1 ===" >>"$EXPORT_LIST_FILE" + echo "" >> "$EXPORT_LIST_FILE" + echo "=== $1 ===" >> "$EXPORT_LIST_FILE" fi } @@ -220,7 +220,7 @@ normalize_paths_for_cleanup() { done fi [[ "$is_child" == "true" ]] || result_paths+=("$path") - done <<<"$sorted_paths" + done <<< "$sorted_paths" if [[ ${#result_paths[@]} -gt 0 ]]; then printf '%s\n' "${result_paths[@]}" @@ -232,9 +232,9 @@ get_cleanup_path_size_kb() { local path="$1" if [[ -f "$path" && ! -L "$path" ]]; then - if command -v stat >/dev/null 2>&1; then + if command -v stat > /dev/null 2>&1; then local bytes - bytes=$(stat -f%z "$path" 2>/dev/null || echo "0") + bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then echo $(((bytes + 1023) / 1024)) return 0 @@ -243,9 +243,9 @@ get_cleanup_path_size_kb() { fi if [[ -L "$path" ]]; then - if command -v stat >/dev/null 2>&1; then + if command -v stat > /dev/null 2>&1; then local bytes - bytes=$(stat -f%z "$path" 2>/dev/null || echo "0") + bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then echo $(((bytes + 1023) / 1024)) else @@ -465,9 +465,9 @@ safe_clean() { [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 if [[ "$size" -gt 0 ]]; then - echo "$size 1" >"$temp_dir/result_${idx}" + echo "$size 1" > "$temp_dir/result_${idx}" else - echo "0 0" >"$temp_dir/result_${idx}" + echo "0 0" > "$temp_dir/result_${idx}" fi ((idx++)) @@ -492,17 +492,17 @@ safe_clean() { [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 local tmp_file="$temp_dir/result_${idx}.$$" if [[ "$size" -gt 0 ]]; then - echo "$size 1" >"$tmp_file" + echo "$size 1" > "$tmp_file" else - echo "0 0" >"$tmp_file" + echo "0 0" > "$tmp_file" fi - mv "$tmp_file" "$temp_dir/result_${idx}" 2>/dev/null || true + mv "$tmp_file" "$temp_dir/result_${idx}" 2> /dev/null || true ) & pids+=($!) ((idx++)) if ((${#pids[@]} >= MOLE_MAX_PARALLEL_JOBS)); then - wait "${pids[0]}" 2>/dev/null || true + wait "${pids[0]}" 2> /dev/null || true pids=("${pids[@]:1}") ((completed++)) @@ -515,7 +515,7 @@ safe_clean() { if [[ ${#pids[@]} -gt 0 ]]; then for pid in "${pids[@]}"; do - wait "$pid" 2>/dev/null || true + wait "$pid" 2> /dev/null || true ((completed++)) if [[ "$show_spinner" == "true" && -t 1 ]]; then @@ -536,11 +536,11 @@ safe_clean() { for path in "${existing_paths[@]}"; do local result_file="$temp_dir/result_${idx}" if [[ -f "$result_file" ]]; then - read -r size count <"$result_file" 2>/dev/null || true + read -r size count < "$result_file" 2> /dev/null || true local removed=0 if [[ "$DRY_RUN" != "true" ]]; then if [[ -L "$path" ]]; then - rm "$path" 2>/dev/null && removed=1 + rm "$path" 2> /dev/null && removed=1 else if safe_remove "$path" true; then removed=1 @@ -582,7 +582,7 @@ safe_clean() { local removed=0 if [[ "$DRY_RUN" != "true" ]]; then if [[ -L "$path" ]]; then - rm "$path" 2>/dev/null && removed=1 + rm "$path" 2> /dev/null && removed=1 else if safe_remove "$path" true; then removed=1 @@ -643,9 +643,9 @@ safe_clean() { local size=0 if [[ -n "${temp_dir:-}" && -f "$temp_dir/result_${idx}" ]]; then - read -r size count <"$temp_dir/result_${idx}" 2>/dev/null || true + read -r size count < "$temp_dir/result_${idx}" 2> /dev/null || true else - size=$(get_cleanup_path_size_kb "$path" 2>/dev/null || echo "0") + size=$(get_cleanup_path_size_kb "$path" 2> /dev/null || echo "0") fi [[ "$size" == "0" || -z "$size" ]] && { @@ -653,7 +653,7 @@ safe_clean() { continue } - echo "$(dirname "$path")|$size|$path" >>"$paths_temp" + echo "$(dirname "$path")|$size|$path" >> "$paths_temp" ((idx++)) done fi @@ -684,9 +684,9 @@ safe_clean() { ' | while IFS='|' read -r display_path total_size child_count; do local size_human=$(bytes_to_human "$((total_size * 1024))") if [[ $child_count -gt 1 ]]; then - echo "$display_path # $size_human, $child_count items" >>"$EXPORT_LIST_FILE" + echo "$display_path # $size_human, $child_count items" >> "$EXPORT_LIST_FILE" else - echo "$display_path # $size_human" >>"$EXPORT_LIST_FILE" + echo "$display_path # $size_human" >> "$EXPORT_LIST_FILE" fi done @@ -726,7 +726,7 @@ start_cleanup() { SYSTEM_CLEAN=false ensure_user_file "$EXPORT_LIST_FILE" - cat >"$EXPORT_LIST_FILE" < "$EXPORT_LIST_FILE" << EOF # Mole Cleanup Preview - $(date '+%Y-%m-%d %H:%M:%S') # # How to protect files: @@ -742,7 +742,7 @@ EOF fi if [[ -t 0 ]]; then - if sudo -n true 2>/dev/null; then + if sudo -n true 2> /dev/null; then SYSTEM_CLEAN=true echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access already available" echo "" @@ -782,7 +782,7 @@ EOF else echo "" echo "Running in non-interactive mode" - if sudo -n true 2>/dev/null; then + if sudo -n true 2> /dev/null; then SYSTEM_CLEAN=true echo " ${ICON_LIST} System-level cleanup enabled, sudo session active" else @@ -1033,7 +1033,7 @@ perform_cleanup() { echo "# Potential cleanup: ${freed_gb}GB" echo "# Items: $files_cleaned" echo "# Categories: $total_items" - } >>"$EXPORT_LIST_FILE" + } >> "$EXPORT_LIST_FILE" summary_details+=("Detailed file list: ${GRAY}$EXPORT_LIST_FILE${NC}") summary_details+=("Use ${GRAY}mo clean --whitelist${NC} to add protection rules") @@ -1085,18 +1085,18 @@ perform_cleanup() { main() { for arg in "$@"; do case "$arg" in - "--debug") - export MO_DEBUG=1 - ;; - "--dry-run" | "-n") - DRY_RUN=true - export MOLE_DRY_RUN=1 - ;; - "--whitelist") - source "$SCRIPT_DIR/../lib/manage/whitelist.sh" - manage_whitelist "clean" - exit 0 - ;; + "--debug") + export MO_DEBUG=1 + ;; + "--dry-run" | "-n") + DRY_RUN=true + export MOLE_DRY_RUN=1 + ;; + "--whitelist") + source "$SCRIPT_DIR/../lib/manage/whitelist.sh" + manage_whitelist "clean" + exit 0 + ;; esac done From 0fbf88a6c66895f9b8e51791793e779f1e3336e9 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 26 Jan 2026 15:43:11 +0800 Subject: [PATCH 81/91] fix: harden cleanup path validation --- SECURITY_AUDIT.md | 15 +++++-- bin/clean.sh | 100 +++++++++++++++++++---------------------- lib/clean/apps.sh | 2 +- lib/uninstall/batch.sh | 10 ++++- 4 files changed, 66 insertions(+), 61 deletions(-) diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index ebec8fc..52551e5 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -2,7 +2,7 @@
-**Status:** PASSED | **Risk Level:** LOW | **Version:** 1.22.1 (2026-01-17) +**Status:** PASSED | **Risk Level:** LOW | **Version:** 1.23.2 (2026-01-26)
@@ -12,24 +12,31 @@ | Attribute | Details | |-----------|---------| -| Audit Date | January 17, 2026 | +| Audit Date | January 26, 2026 | | Audit Conclusion | **PASSED** | -| Mole Version | V1.22.0 | +| Mole Version | V1.23.2 | | Audited Branch | `main` (HEAD) | | Scope | Shell scripts, Go binaries, Configuration | | Methodology | Static analysis, Threat modeling, Code review | | Review Cycle | Every 6 months or after major feature additions | -| Next Review | June 2026 | +| Next Review | July 2026 | **Key Findings:** - Multi-layer validation effectively blocks risky system modifications. - Conservative cleaning logic ensures safety (e.g., 60-day dormancy rule). - Comprehensive protection for VPNs, AI tools, and core system components. +- Operations logging improves traceability while remaining optional (MO_NO_OPLOG=1). - Atomic operations prevent state corruption during crashes. - Dry-run and whitelist features give users full control. - Installer cleanup scans safely and requires user confirmation. +**Recent Remediations:** + +- Symlink cleanup in `bin/clean.sh` now routes through `safe_remove` for target validation. +- Orphaned helper cleanup in `lib/clean/apps.sh` now uses `safe_sudo_remove`. +- ByHost preference cleanup in `lib/uninstall/batch.sh` validates bundle IDs and deletes via `safe_remove`. + --- ## Security Philosophy diff --git a/bin/clean.sh b/bin/clean.sh index b8858d4..8f700c1 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -69,10 +69,10 @@ if [[ -f "$HOME/.config/mole/whitelist" ]]; then fi case "$line" in - / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /etc | /etc/* | /var/db | /var/db/*) - WHITELIST_WARNINGS+=("Protected system path: $line") - continue - ;; + / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /etc | /etc/* | /var/db | /var/db/*) + WHITELIST_WARNINGS+=("Protected system path: $line") + continue + ;; esac duplicate="false" @@ -86,7 +86,7 @@ if [[ -f "$HOME/.config/mole/whitelist" ]]; then fi [[ "$duplicate" == "true" ]] && continue WHITELIST_PATTERNS+=("$line") - done <"$HOME/.config/mole/whitelist" + done < "$HOME/.config/mole/whitelist" else WHITELIST_PATTERNS=("${DEFAULT_WHITELIST_PATTERNS[@]}") fi @@ -140,7 +140,7 @@ cleanup() { fi CLEANUP_DONE=true - stop_inline_spinner 2>/dev/null || true + stop_inline_spinner 2> /dev/null || true if [[ -t 1 ]]; then printf "\r\033[K" >&2 || true @@ -166,8 +166,8 @@ start_section() { if [[ "$DRY_RUN" == "true" ]]; then ensure_user_file "$EXPORT_LIST_FILE" - echo "" >>"$EXPORT_LIST_FILE" - echo "=== $1 ===" >>"$EXPORT_LIST_FILE" + echo "" >> "$EXPORT_LIST_FILE" + echo "=== $1 ===" >> "$EXPORT_LIST_FILE" fi } @@ -220,7 +220,7 @@ normalize_paths_for_cleanup() { done fi [[ "$is_child" == "true" ]] || result_paths+=("$path") - done <<<"$sorted_paths" + done <<< "$sorted_paths" if [[ ${#result_paths[@]} -gt 0 ]]; then printf '%s\n' "${result_paths[@]}" @@ -232,9 +232,9 @@ get_cleanup_path_size_kb() { local path="$1" if [[ -f "$path" && ! -L "$path" ]]; then - if command -v stat >/dev/null 2>&1; then + if command -v stat > /dev/null 2>&1; then local bytes - bytes=$(stat -f%z "$path" 2>/dev/null || echo "0") + bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then echo $(((bytes + 1023) / 1024)) return 0 @@ -243,9 +243,9 @@ get_cleanup_path_size_kb() { fi if [[ -L "$path" ]]; then - if command -v stat >/dev/null 2>&1; then + if command -v stat > /dev/null 2>&1; then local bytes - bytes=$(stat -f%z "$path" 2>/dev/null || echo "0") + bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then echo $(((bytes + 1023) / 1024)) else @@ -465,9 +465,9 @@ safe_clean() { [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 if [[ "$size" -gt 0 ]]; then - echo "$size 1" >"$temp_dir/result_${idx}" + echo "$size 1" > "$temp_dir/result_${idx}" else - echo "0 0" >"$temp_dir/result_${idx}" + echo "0 0" > "$temp_dir/result_${idx}" fi ((idx++)) @@ -492,17 +492,17 @@ safe_clean() { [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 local tmp_file="$temp_dir/result_${idx}.$$" if [[ "$size" -gt 0 ]]; then - echo "$size 1" >"$tmp_file" + echo "$size 1" > "$tmp_file" else - echo "0 0" >"$tmp_file" + echo "0 0" > "$tmp_file" fi - mv "$tmp_file" "$temp_dir/result_${idx}" 2>/dev/null || true + mv "$tmp_file" "$temp_dir/result_${idx}" 2> /dev/null || true ) & pids+=($!) ((idx++)) if ((${#pids[@]} >= MOLE_MAX_PARALLEL_JOBS)); then - wait "${pids[0]}" 2>/dev/null || true + wait "${pids[0]}" 2> /dev/null || true pids=("${pids[@]:1}") ((completed++)) @@ -515,7 +515,7 @@ safe_clean() { if [[ ${#pids[@]} -gt 0 ]]; then for pid in "${pids[@]}"; do - wait "$pid" 2>/dev/null || true + wait "$pid" 2> /dev/null || true ((completed++)) if [[ "$show_spinner" == "true" && -t 1 ]]; then @@ -536,15 +536,11 @@ safe_clean() { for path in "${existing_paths[@]}"; do local result_file="$temp_dir/result_${idx}" if [[ -f "$result_file" ]]; then - read -r size count <"$result_file" 2>/dev/null || true + read -r size count < "$result_file" 2> /dev/null || true local removed=0 if [[ "$DRY_RUN" != "true" ]]; then - if [[ -L "$path" ]]; then - rm "$path" 2>/dev/null && removed=1 - else - if safe_remove "$path" true; then - removed=1 - fi + if safe_remove "$path" true; then + removed=1 fi else removed=1 @@ -581,12 +577,8 @@ safe_clean() { local removed=0 if [[ "$DRY_RUN" != "true" ]]; then - if [[ -L "$path" ]]; then - rm "$path" 2>/dev/null && removed=1 - else - if safe_remove "$path" true; then - removed=1 - fi + if safe_remove "$path" true; then + removed=1 fi else removed=1 @@ -643,9 +635,9 @@ safe_clean() { local size=0 if [[ -n "${temp_dir:-}" && -f "$temp_dir/result_${idx}" ]]; then - read -r size count <"$temp_dir/result_${idx}" 2>/dev/null || true + read -r size count < "$temp_dir/result_${idx}" 2> /dev/null || true else - size=$(get_cleanup_path_size_kb "$path" 2>/dev/null || echo "0") + size=$(get_cleanup_path_size_kb "$path" 2> /dev/null || echo "0") fi [[ "$size" == "0" || -z "$size" ]] && { @@ -653,7 +645,7 @@ safe_clean() { continue } - echo "$(dirname "$path")|$size|$path" >>"$paths_temp" + echo "$(dirname "$path")|$size|$path" >> "$paths_temp" ((idx++)) done fi @@ -684,9 +676,9 @@ safe_clean() { ' | while IFS='|' read -r display_path total_size child_count; do local size_human=$(bytes_to_human "$((total_size * 1024))") if [[ $child_count -gt 1 ]]; then - echo "$display_path # $size_human, $child_count items" >>"$EXPORT_LIST_FILE" + echo "$display_path # $size_human, $child_count items" >> "$EXPORT_LIST_FILE" else - echo "$display_path # $size_human" >>"$EXPORT_LIST_FILE" + echo "$display_path # $size_human" >> "$EXPORT_LIST_FILE" fi done @@ -726,7 +718,7 @@ start_cleanup() { SYSTEM_CLEAN=false ensure_user_file "$EXPORT_LIST_FILE" - cat >"$EXPORT_LIST_FILE" < "$EXPORT_LIST_FILE" << EOF # Mole Cleanup Preview - $(date '+%Y-%m-%d %H:%M:%S') # # How to protect files: @@ -742,7 +734,7 @@ EOF fi if [[ -t 0 ]]; then - if sudo -n true 2>/dev/null; then + if sudo -n true 2> /dev/null; then SYSTEM_CLEAN=true echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access already available" echo "" @@ -782,7 +774,7 @@ EOF else echo "" echo "Running in non-interactive mode" - if sudo -n true 2>/dev/null; then + if sudo -n true 2> /dev/null; then SYSTEM_CLEAN=true echo " ${ICON_LIST} System-level cleanup enabled, sudo session active" else @@ -1033,7 +1025,7 @@ perform_cleanup() { echo "# Potential cleanup: ${freed_gb}GB" echo "# Items: $files_cleaned" echo "# Categories: $total_items" - } >>"$EXPORT_LIST_FILE" + } >> "$EXPORT_LIST_FILE" summary_details+=("Detailed file list: ${GRAY}$EXPORT_LIST_FILE${NC}") summary_details+=("Use ${GRAY}mo clean --whitelist${NC} to add protection rules") @@ -1085,18 +1077,18 @@ perform_cleanup() { main() { for arg in "$@"; do case "$arg" in - "--debug") - export MO_DEBUG=1 - ;; - "--dry-run" | "-n") - DRY_RUN=true - export MOLE_DRY_RUN=1 - ;; - "--whitelist") - source "$SCRIPT_DIR/../lib/manage/whitelist.sh" - manage_whitelist "clean" - exit 0 - ;; + "--debug") + export MO_DEBUG=1 + ;; + "--dry-run" | "-n") + DRY_RUN=true + export MOLE_DRY_RUN=1 + ;; + "--whitelist") + source "$SCRIPT_DIR/../lib/manage/whitelist.sh" + manage_whitelist "clean" + exit 0 + ;; esac done diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index f3e084b..9fffe40 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -500,7 +500,7 @@ clean_orphaned_system_services() { if [[ "$orphan_file" == *.plist ]]; then sudo launchctl unload "$orphan_file" 2> /dev/null || true fi - if sudo rm -f "$orphan_file" 2> /dev/null; then + if safe_sudo_remove "$orphan_file"; then debug_log "Removed orphaned service: $orphan_file" fi fi diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index dba6e55..9c405ef 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -502,8 +502,14 @@ batch_uninstall_applications() { fi # ByHost preferences (machine-specific). - if [[ -d ~/Library/Preferences/ByHost ]]; then - find ~/Library/Preferences/ByHost -maxdepth 1 -name "${bundle_id}.*.plist" -delete 2> /dev/null || true + if [[ -d "$HOME/Library/Preferences/ByHost" ]]; then + if [[ "$bundle_id" =~ ^[A-Za-z0-9._-]+$ ]]; then + while IFS= read -r -d '' plist_file; do + safe_remove "$plist_file" true > /dev/null || true + done < <(command find "$HOME/Library/Preferences/ByHost" -maxdepth 1 -type f -name "${bundle_id}.*.plist" -print0 2> /dev/null || true) + else + debug_log "Skipping ByHost cleanup, invalid bundle id: $bundle_id" + fi fi fi From 09f0cf9e53f6bd288dad93e672fc9d98bf097f98 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 26 Jan 2026 07:46:58 +0000 Subject: [PATCH 82/91] chore: auto format code --- bin/clean.sh | 84 ++++++++++++++++++++++++++-------------------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/bin/clean.sh b/bin/clean.sh index e9b3d0e..8f700c1 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -69,10 +69,10 @@ if [[ -f "$HOME/.config/mole/whitelist" ]]; then fi case "$line" in - / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /etc | /etc/* | /var/db | /var/db/*) - WHITELIST_WARNINGS+=("Protected system path: $line") - continue - ;; + / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /etc | /etc/* | /var/db | /var/db/*) + WHITELIST_WARNINGS+=("Protected system path: $line") + continue + ;; esac duplicate="false" @@ -86,7 +86,7 @@ if [[ -f "$HOME/.config/mole/whitelist" ]]; then fi [[ "$duplicate" == "true" ]] && continue WHITELIST_PATTERNS+=("$line") - done <"$HOME/.config/mole/whitelist" + done < "$HOME/.config/mole/whitelist" else WHITELIST_PATTERNS=("${DEFAULT_WHITELIST_PATTERNS[@]}") fi @@ -140,7 +140,7 @@ cleanup() { fi CLEANUP_DONE=true - stop_inline_spinner 2>/dev/null || true + stop_inline_spinner 2> /dev/null || true if [[ -t 1 ]]; then printf "\r\033[K" >&2 || true @@ -166,8 +166,8 @@ start_section() { if [[ "$DRY_RUN" == "true" ]]; then ensure_user_file "$EXPORT_LIST_FILE" - echo "" >>"$EXPORT_LIST_FILE" - echo "=== $1 ===" >>"$EXPORT_LIST_FILE" + echo "" >> "$EXPORT_LIST_FILE" + echo "=== $1 ===" >> "$EXPORT_LIST_FILE" fi } @@ -220,7 +220,7 @@ normalize_paths_for_cleanup() { done fi [[ "$is_child" == "true" ]] || result_paths+=("$path") - done <<<"$sorted_paths" + done <<< "$sorted_paths" if [[ ${#result_paths[@]} -gt 0 ]]; then printf '%s\n' "${result_paths[@]}" @@ -232,9 +232,9 @@ get_cleanup_path_size_kb() { local path="$1" if [[ -f "$path" && ! -L "$path" ]]; then - if command -v stat >/dev/null 2>&1; then + if command -v stat > /dev/null 2>&1; then local bytes - bytes=$(stat -f%z "$path" 2>/dev/null || echo "0") + bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then echo $(((bytes + 1023) / 1024)) return 0 @@ -243,9 +243,9 @@ get_cleanup_path_size_kb() { fi if [[ -L "$path" ]]; then - if command -v stat >/dev/null 2>&1; then + if command -v stat > /dev/null 2>&1; then local bytes - bytes=$(stat -f%z "$path" 2>/dev/null || echo "0") + bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then echo $(((bytes + 1023) / 1024)) else @@ -465,9 +465,9 @@ safe_clean() { [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 if [[ "$size" -gt 0 ]]; then - echo "$size 1" >"$temp_dir/result_${idx}" + echo "$size 1" > "$temp_dir/result_${idx}" else - echo "0 0" >"$temp_dir/result_${idx}" + echo "0 0" > "$temp_dir/result_${idx}" fi ((idx++)) @@ -492,17 +492,17 @@ safe_clean() { [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 local tmp_file="$temp_dir/result_${idx}.$$" if [[ "$size" -gt 0 ]]; then - echo "$size 1" >"$tmp_file" + echo "$size 1" > "$tmp_file" else - echo "0 0" >"$tmp_file" + echo "0 0" > "$tmp_file" fi - mv "$tmp_file" "$temp_dir/result_${idx}" 2>/dev/null || true + mv "$tmp_file" "$temp_dir/result_${idx}" 2> /dev/null || true ) & pids+=($!) ((idx++)) if ((${#pids[@]} >= MOLE_MAX_PARALLEL_JOBS)); then - wait "${pids[0]}" 2>/dev/null || true + wait "${pids[0]}" 2> /dev/null || true pids=("${pids[@]:1}") ((completed++)) @@ -515,7 +515,7 @@ safe_clean() { if [[ ${#pids[@]} -gt 0 ]]; then for pid in "${pids[@]}"; do - wait "$pid" 2>/dev/null || true + wait "$pid" 2> /dev/null || true ((completed++)) if [[ "$show_spinner" == "true" && -t 1 ]]; then @@ -536,7 +536,7 @@ safe_clean() { for path in "${existing_paths[@]}"; do local result_file="$temp_dir/result_${idx}" if [[ -f "$result_file" ]]; then - read -r size count <"$result_file" 2>/dev/null || true + read -r size count < "$result_file" 2> /dev/null || true local removed=0 if [[ "$DRY_RUN" != "true" ]]; then if safe_remove "$path" true; then @@ -635,9 +635,9 @@ safe_clean() { local size=0 if [[ -n "${temp_dir:-}" && -f "$temp_dir/result_${idx}" ]]; then - read -r size count <"$temp_dir/result_${idx}" 2>/dev/null || true + read -r size count < "$temp_dir/result_${idx}" 2> /dev/null || true else - size=$(get_cleanup_path_size_kb "$path" 2>/dev/null || echo "0") + size=$(get_cleanup_path_size_kb "$path" 2> /dev/null || echo "0") fi [[ "$size" == "0" || -z "$size" ]] && { @@ -645,7 +645,7 @@ safe_clean() { continue } - echo "$(dirname "$path")|$size|$path" >>"$paths_temp" + echo "$(dirname "$path")|$size|$path" >> "$paths_temp" ((idx++)) done fi @@ -676,9 +676,9 @@ safe_clean() { ' | while IFS='|' read -r display_path total_size child_count; do local size_human=$(bytes_to_human "$((total_size * 1024))") if [[ $child_count -gt 1 ]]; then - echo "$display_path # $size_human, $child_count items" >>"$EXPORT_LIST_FILE" + echo "$display_path # $size_human, $child_count items" >> "$EXPORT_LIST_FILE" else - echo "$display_path # $size_human" >>"$EXPORT_LIST_FILE" + echo "$display_path # $size_human" >> "$EXPORT_LIST_FILE" fi done @@ -718,7 +718,7 @@ start_cleanup() { SYSTEM_CLEAN=false ensure_user_file "$EXPORT_LIST_FILE" - cat >"$EXPORT_LIST_FILE" < "$EXPORT_LIST_FILE" << EOF # Mole Cleanup Preview - $(date '+%Y-%m-%d %H:%M:%S') # # How to protect files: @@ -734,7 +734,7 @@ EOF fi if [[ -t 0 ]]; then - if sudo -n true 2>/dev/null; then + if sudo -n true 2> /dev/null; then SYSTEM_CLEAN=true echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access already available" echo "" @@ -774,7 +774,7 @@ EOF else echo "" echo "Running in non-interactive mode" - if sudo -n true 2>/dev/null; then + if sudo -n true 2> /dev/null; then SYSTEM_CLEAN=true echo " ${ICON_LIST} System-level cleanup enabled, sudo session active" else @@ -1025,7 +1025,7 @@ perform_cleanup() { echo "# Potential cleanup: ${freed_gb}GB" echo "# Items: $files_cleaned" echo "# Categories: $total_items" - } >>"$EXPORT_LIST_FILE" + } >> "$EXPORT_LIST_FILE" summary_details+=("Detailed file list: ${GRAY}$EXPORT_LIST_FILE${NC}") summary_details+=("Use ${GRAY}mo clean --whitelist${NC} to add protection rules") @@ -1077,18 +1077,18 @@ perform_cleanup() { main() { for arg in "$@"; do case "$arg" in - "--debug") - export MO_DEBUG=1 - ;; - "--dry-run" | "-n") - DRY_RUN=true - export MOLE_DRY_RUN=1 - ;; - "--whitelist") - source "$SCRIPT_DIR/../lib/manage/whitelist.sh" - manage_whitelist "clean" - exit 0 - ;; + "--debug") + export MO_DEBUG=1 + ;; + "--dry-run" | "-n") + DRY_RUN=true + export MOLE_DRY_RUN=1 + ;; + "--whitelist") + source "$SCRIPT_DIR/../lib/manage/whitelist.sh" + manage_whitelist "clean" + exit 0 + ;; esac done From 3919a7030070ad0156acd6beb1642899d483f460 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 26 Jan 2026 20:27:46 +0800 Subject: [PATCH 83/91] fix: enhance uninstall security per audit review - Validate bundle_id format (reverse-DNS) in stop_launch_services() to prevent glob injection attacks - Add common word exclusion list for LaunchAgents name search to avoid false positive matches (Music, Notes, Photos, etc.) - Add security comments explaining symlink handling in remove_file_list() - Improve brew_uninstall_cask() timeout handling: exit code 124 now returns failure immediately - Update SECURITY_AUDIT.md with remediation details --- SECURITY_AUDIT.md | 7 +++++++ lib/core/app_protection.sh | 23 ++++++++++++++++------- lib/uninstall/batch.sh | 15 +++++++++++++++ lib/uninstall/brew.sh | 9 ++++++++- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 52551e5..c28fd77 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -33,6 +33,11 @@ **Recent Remediations:** +- **Uninstall Audit (Jan 2026)**: Enhanced security in uninstall logic per comprehensive security review. + - `stop_launch_services()` now validates bundle_id format (reverse-DNS) before use in find patterns to prevent glob injection attacks. + - `find_app_files()` LaunchAgents search now excludes common words (Music, Notes, etc.) to prevent false positive matches. + - `remove_file_list()` symlink handling documented with detailed security comments explaining the TOCTOU protection bypass rationale. + - `brew_uninstall_cask()` timeout handling improved: exit code 124 (timeout) now returns failure immediately without verification. - Symlink cleanup in `bin/clean.sh` now routes through `safe_remove` for target validation. - Orphaned helper cleanup in `lib/clean/apps.sh` now uses `safe_sudo_remove`. - ByHost preference cleanup in `lib/uninstall/batch.sh` validates bundle IDs and deletes via `safe_remove`. @@ -199,6 +204,8 @@ When users uninstall applications via `mo uninstall`, Mole automatically removes - Unloads services via `launchctl` before deletion (via `stop_launch_services()`) - **Safer than orphan detection:** Only removes plists when the associated app is explicitly being uninstalled - Prevents accumulation of orphaned startup items that persist after app removal +- **Common word exclusion:** LaunchAgent name searches exclude generic terms (Music, Notes, Photos, etc.) to prevent false positives +- **Bundle ID validation:** `stop_launch_services()` validates reverse-DNS format before find patterns **Code:** `lib/core/app_protection.sh:find_app_files()`, `lib/uninstall/batch.sh:stop_launch_services()` diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 1b754b8..446a478 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -768,14 +768,23 @@ find_app_files() { # Note: LaunchDaemons are system-level and handled in find_app_system_files() # Minimum 5-char threshold prevents false positives (e.g., "Time" matching system agents) # Short-name apps (e.g., Zoom, Arc) are still cleaned via bundle_id matching above + # Security: Common words are excluded to prevent matching unrelated plist files if [[ ${#app_name} -ge 5 ]] && [[ -d ~/Library/LaunchAgents ]]; then - while IFS= read -r -d '' plist; do - local plist_name=$(basename "$plist") - if [[ "$plist_name" =~ ^com\.apple\. ]]; then - continue - fi - files_to_clean+=("$plist") - done < <(command find ~/Library/LaunchAgents -maxdepth 1 -name "*$app_name*.plist" -print0 2> /dev/null) + # Skip common words that could match many unrelated LaunchAgents + # These are either generic terms or names that overlap with system/common utilities + local common_words="Music|Notes|Photos|Finder|Safari|Preview|Calendar|Contacts|Messages|Reminders|Clock|Weather|Stocks|Books|News|Podcasts|Voice|Files|Store|System|Helper|Agent|Daemon|Service|Update|Sync|Backup|Cloud|Manager|Monitor|Server|Client|Worker|Runner|Launcher|Driver|Plugin|Extension|Widget|Utility" + if [[ "$app_name" =~ ^($common_words)$ ]]; then + debug_log "Skipping LaunchAgent name search for common word: $app_name" + else + while IFS= read -r -d '' plist; do + local plist_name=$(basename "$plist") + # Skip Apple's LaunchAgents + if [[ "$plist_name" =~ ^com\.apple\. ]]; then + continue + fi + files_to_clean+=("$plist") + done < <(command find ~/Library/LaunchAgents -maxdepth 1 -name "*$app_name*.plist" -print0 2> /dev/null) + fi fi # Handle specialized toolchains and development environments diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 9c405ef..5f11973 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -72,12 +72,20 @@ decode_file_list() { # Note: find_app_files() and calculate_total_size() are in lib/core/common.sh. # Stop Launch Agents/Daemons for an app. +# Security: bundle_id is validated to be reverse-DNS format before use in find patterns stop_launch_services() { local bundle_id="$1" local has_system_files="${2:-false}" [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0 + # Validate bundle_id format: must be reverse-DNS style (e.g., com.example.app) + # This prevents glob injection attacks if bundle_id contains special characters + if [[ ! "$bundle_id" =~ ^[a-zA-Z0-9][-a-zA-Z0-9]*(\.[a-zA-Z0-9][-a-zA-Z0-9]*)+$ ]]; then + debug_log "Invalid bundle_id format for LaunchAgent search: $bundle_id" + return 0 + fi + if [[ -d ~/Library/LaunchAgents ]]; then while IFS= read -r -d '' plist; do launchctl unload "$plist" 2> /dev/null || true @@ -135,6 +143,7 @@ remove_login_item() { } # Remove files (handles symlinks, optional sudo). +# Security: All paths pass validate_path_for_deletion() before any deletion. remove_file_list() { local file_list="$1" local use_sudo="${2:-false}" @@ -147,6 +156,12 @@ remove_file_list() { continue fi + # Symlinks are handled separately using rm (not safe_remove/safe_sudo_remove) + # because safe_sudo_remove() refuses symlinks entirely as a TOCTOU protection. + # This is safe because: + # 1. The path has already passed validate_path_for_deletion() above + # 2. rm on a symlink only removes the link itself, NOT the target + # 3. The symlink deletion is logged via operations.log if [[ -L "$file" ]]; then if [[ "$use_sudo" == "true" ]]; then sudo rm "$file" 2> /dev/null && ((++count)) || true diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index 5e2f13f..adba229 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -196,15 +196,22 @@ brew_uninstall_cask() { fi # Run with timeout to prevent hangs from problematic cask scripts + local brew_exit=0 if HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ run_with_timeout "$timeout" brew uninstall --cask "$cask_name" 2>&1; then uninstall_ok=true else brew_exit=$? debug_log "brew uninstall timeout or failed with exit code: $brew_exit" + # Exit code 124 indicates timeout from run_with_timeout + # On timeout, fail immediately without verification to avoid inconsistent state + if [[ $brew_exit -eq 124 ]]; then + debug_log "brew uninstall timed out after ${timeout}s, returning failure" + return 1 + fi fi - # Verify removal + # Verify removal (only if not timed out) local cask_gone=true app_gone=true HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "$cask_name" && cask_gone=false [[ -n "$app_path" && -e "$app_path" ]] && app_gone=false From 162ba949ee86228a3b601559289d42e53bfc995e Mon Sep 17 00:00:00 2001 From: Dylan Joss Date: Mon, 26 Jan 2026 14:05:02 -0800 Subject: [PATCH 84/91] test: expand cleanable directory detection tests Enhance test coverage for `isCleanableDir` and add new tests for `isHandledByMoClean` in `cmd/analyze/analyze_test.go`. `TestIsHandledByMoClean` (new, 12 test cases): - Paths `mo clean` handles (caches, logs, saved state, trash, reports) - Paths `mo clean` does NOT handle (project dirs, home, random) - Case sensitivity verification `TestIsCleanableDir` (expanded from 3 to 24 test cases): - Project dependencies (`node_modules`, `venv`, `pycache`, `build`, etc.) - Xcode/iOS directories (DerivedData, Pods) - Build outputs (dist, target, .next, coverage) - Paths excluded because `mo clean` handles them - Edge cases (empty string, root path, just basename) --- cmd/analyze/analyze_test.go | 88 ++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/cmd/analyze/analyze_test.go b/cmd/analyze/analyze_test.go index 083b2e6..243b959 100644 --- a/cmd/analyze/analyze_test.go +++ b/cmd/analyze/analyze_test.go @@ -242,15 +242,89 @@ func TestMeasureOverviewSize(t *testing.T) { } } +func TestIsHandledByMoClean(t *testing.T) { + tests := []struct { + name string + path string + want bool + }{ + // Paths mo clean handles. + {"user caches", "/Users/test/Library/Caches/com.example", true}, + {"user logs", "/Users/test/Library/Logs/DiagnosticReports", true}, + {"saved app state", "/Users/test/Library/Saved Application State/com.example", true}, + {"user trash", "/Users/test/.Trash/deleted-file", true}, + {"diagnostic reports", "/Users/test/Library/DiagnosticReports/crash.log", true}, + + // Paths mo clean does NOT handle. + {"project node_modules", "/Users/test/project/node_modules", false}, + {"project build", "/Users/test/project/build", false}, + {"home directory", "/Users/test", false}, + {"random path", "/some/random/path", false}, + {"empty string", "", false}, + + // Partial matches should not trigger (case sensitive). + {"lowercase caches", "/users/test/library/caches/foo", false}, + {"different trash path", "/Users/test/Trash/file", false}, // Missing dot prefix + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isHandledByMoClean(tt.path) + if got != tt.want { + t.Errorf("isHandledByMoClean(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} + func TestIsCleanableDir(t *testing.T) { - if !isCleanableDir("/Users/test/project/node_modules") { - t.Fatalf("expected node_modules to be cleanable") + tests := []struct { + name string + path string + want bool + }{ + // Empty path. + {"empty string", "", false}, + + // Project dependencies (should be cleanable). + {"node_modules", "/Users/test/project/node_modules", true}, + {"nested node_modules", "/Users/test/project/packages/app/node_modules", true}, + {"venv", "/Users/test/project/venv", true}, + {"dot venv", "/Users/test/project/.venv", true}, + {"pycache", "/Users/test/project/src/__pycache__", true}, + {"build dir", "/Users/test/project/build", true}, + {"dist dir", "/Users/test/project/dist", true}, + {"target dir", "/Users/test/project/target", true}, + {"next.js cache", "/Users/test/project/.next", true}, + {"DerivedData", "/Users/test/Library/Developer/Xcode/DerivedData", true}, + {"Pods", "/Users/test/project/ios/Pods", true}, + {"gradle cache", "/Users/test/project/.gradle", true}, + {"coverage", "/Users/test/project/coverage", true}, + {"terraform", "/Users/test/infra/.terraform", true}, + + // Paths handled by mo clean (should NOT be cleanable). + {"user caches", "/Users/test/Library/Caches/com.example", false}, + {"user logs", "/Users/test/Library/Logs/app.log", false}, + {"trash", "/Users/test/.Trash/deleted", false}, + + // Not in projectDependencyDirs. + {"src dir", "/Users/test/project/src", false}, + {"random dir", "/Users/test/project/random", false}, + {"home dir", "/Users/test", false}, + {".git dir", "/Users/test/project/.git", false}, + + // Edge cases. + {"just basename node_modules", "node_modules", true}, + {"root path", "/", false}, } - if isCleanableDir("/Users/test/Library/Caches/AppCache") { - t.Fatalf("Library caches should be handled by mo clean") - } - if isCleanableDir("") { - t.Fatalf("empty path should not be cleanable") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isCleanableDir(tt.path) + if got != tt.want { + t.Errorf("isCleanableDir(%q) = %v, want %v", tt.path, got, tt.want) + } + }) } } From ac7977396500e5dc2628d96539ba4bd4ed308f0d Mon Sep 17 00:00:00 2001 From: Andrei Murariu <83287213+iamxorum@users.noreply.github.com> Date: Tue, 27 Jan 2026 04:04:49 +0200 Subject: [PATCH 85/91] README: `mo analyze` - update the netowkr graph (#366) continued the PR - https://github.com/tw93/Mole/pull/321 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f816754..0fd48c2 100644 --- a/README.md +++ b/README.md @@ -185,8 +185,8 @@ Read ▮▯▯▯▯ 2.1 MB/s Health Normal · 423 cycles Write ▮▮▮▯▯ 18.3 MB/s Temp 58°C · 1200 RPM ⇅ Network ▶ Processes -Down ▮▮▯▯▯ 3.2 MB/s Code ▮▮▮▮▯ 42.1% -Up ▮▯▯▯▯ 0.8 MB/s Chrome ▮▮▮▯▯ 28.3% +Down ▁▁█▂▁▁▁▁▁▁▁▁▇▆▅▂ 0.54 MB/s Code ▮▮▮▮▯ 42.1% +Up ▄▄▄▃▃▃▄▆▆▇█▁▁▁▁▁ 0.02 MB/s Chrome ▮▮▮▯▯ 28.3% Proxy HTTP · 192.168.1.100 Terminal ▮▯▯▯▯ 12.5% ``` From 0fe72245ce3d5d5c12e8ac7b26fcae795c14e48e Mon Sep 17 00:00:00 2001 From: tw93 Date: Tue, 27 Jan 2026 10:22:07 +0800 Subject: [PATCH 86/91] docs: rewrite SECURITY_AUDIT.md as technical reference --- SECURITY_AUDIT.md | 415 ++++++++++++---------------------------------- 1 file changed, 107 insertions(+), 308 deletions(-) diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index c28fd77..af3069d 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -1,370 +1,169 @@ -# Mole Security Audit Report +# Mole Security Reference -
+Version 1.23.2 | 2026-01-26 -**Status:** PASSED | **Risk Level:** LOW | **Version:** 1.23.2 (2026-01-26) +## Recent Fixes -
+**Uninstall audit, Jan 2026:** ---- +- `stop_launch_services()` now checks bundle_id is valid reverse-DNS before using it in find patterns. This stops glob injection. +- `find_app_files()` skips LaunchAgents named after common words like Music or Notes. +- Added comments explaining why `remove_file_list()` bypasses TOCTOU checks for symlinks. +- `brew_uninstall_cask()` treats exit code 124 as timeout failure, returns immediately. -## Audit Overview +Other changes: -| Attribute | Details | -|-----------|---------| -| Audit Date | January 26, 2026 | -| Audit Conclusion | **PASSED** | -| Mole Version | V1.23.2 | -| Audited Branch | `main` (HEAD) | -| Scope | Shell scripts, Go binaries, Configuration | -| Methodology | Static analysis, Threat modeling, Code review | -| Review Cycle | Every 6 months or after major feature additions | -| Next Review | July 2026 | +- Symlink cleanup in `bin/clean.sh` goes through `safe_remove` now +- Orphaned helper cleanup in `lib/clean/apps.sh` switched to `safe_sudo_remove` +- ByHost pref cleanup checks bundle ID format first -**Key Findings:** +## Path Validation -- Multi-layer validation effectively blocks risky system modifications. -- Conservative cleaning logic ensures safety (e.g., 60-day dormancy rule). -- Comprehensive protection for VPNs, AI tools, and core system components. -- Operations logging improves traceability while remaining optional (MO_NO_OPLOG=1). -- Atomic operations prevent state corruption during crashes. -- Dry-run and whitelist features give users full control. -- Installer cleanup scans safely and requires user confirmation. +Every deletion goes through `lib/core/file_ops.sh`. The `validate_path_for_deletion()` function rejects empty paths, paths with `/../` in them, and anything containing control characters like newlines or null bytes. -**Recent Remediations:** +**Blocked paths**, even with sudo: -- **Uninstall Audit (Jan 2026)**: Enhanced security in uninstall logic per comprehensive security review. - - `stop_launch_services()` now validates bundle_id format (reverse-DNS) before use in find patterns to prevent glob injection attacks. - - `find_app_files()` LaunchAgents search now excludes common words (Music, Notes, etc.) to prevent false positive matches. - - `remove_file_list()` symlink handling documented with detailed security comments explaining the TOCTOU protection bypass rationale. - - `brew_uninstall_cask()` timeout handling improved: exit code 124 (timeout) now returns failure immediately without verification. -- Symlink cleanup in `bin/clean.sh` now routes through `safe_remove` for target validation. -- Orphaned helper cleanup in `lib/clean/apps.sh` now uses `safe_sudo_remove`. -- ByHost preference cleanup in `lib/uninstall/batch.sh` validates bundle IDs and deletes via `safe_remove`. - ---- - -## Security Philosophy - -**Core Principle: "Do No Harm"** - -We built Mole on a **Zero Trust** architecture for filesystem operations. Every modification request is treated as dangerous until it passes strict validation. - -**Guiding Priorities:** - -1. **System Stability First** - We'd rather leave 1GB of junk than delete 1KB of your data. -2. **Conservative by Default** - High-risk operations always require explicit confirmation. -3. **Fail Safe** - When in doubt, we abort immediately. -4. **Transparency** - Every operation is logged and allows a preview via dry-run mode. - ---- - -## Threat Model - -### Attack Vectors & Mitigations - -| Threat | Risk Level | Mitigation | Status | -|--------|------------|------------|--------| -| Accidental System File Deletion | Critical | Multi-layer path validation, system directory blocklist | Mitigated | -| Path Traversal Attack | High | Absolute path enforcement, relative path rejection | Mitigated | -| Symlink Exploitation | High | Symlink detection in privileged mode | Mitigated | -| Command Injection | High | Control character filtering, strict validation | Mitigated | -| Empty Variable Deletion | High | Empty path validation, defensive checks | Mitigated | -| Race Conditions | Medium | Atomic operations, process isolation | Mitigated | -| Network Mount Hangs | Medium | Timeout protection, volume type detection | Mitigated | -| Privilege Escalation | Medium | Restricted sudo scope, user home validation | Mitigated | -| False Positive Deletion | Medium | 3-char minimum, fuzzy matching disabled | Mitigated | -| VPN Configuration Loss | Medium | Comprehensive VPN/proxy whitelist | Mitigated | - ---- - -## Defense Architecture - -### Multi-Layered Validation System - -All automated operations pass through hardened middleware (`lib/core/file_ops.sh`) with 4 layers of validation: - -#### Layer 1: Input Sanitization - -| Control | Protection Against | -|---------|---------------------| -| Absolute Path Enforcement | Path traversal attacks (`../etc`) | -| Control Character Filtering | Command injection (`\n`, `\r`, `\0`) | -| Empty Variable Protection | Accidental `rm -rf /` | -| Secure Temp Workspaces | Data leakage, race conditions | - -**Code:** `lib/core/file_ops.sh:validate_path_for_deletion()` - -#### Layer 2: System Path Protection ("Iron Dome") - -Even with `sudo`, these paths are **unconditionally blocked**: - -```bash -/ # Root filesystem -/System # macOS system files -/bin, /sbin, /usr # Core binaries -/etc, /var # System configuration -/Library/Extensions # Kernel extensions -/private # System-private directories +```text +/ # root +/System # macOS system +/bin, /sbin, /usr # binaries +/etc, /var # config +/Library/Extensions # kexts +/private # system private ``` -**Exceptions:** +Some system caches are OK to delete: -- `/System/Library/Caches/com.apple.coresymbolicationd/data` (safe, rebuildable cache) +- `/System/Library/Caches/com.apple.coresymbolicationd/data` - `/private/tmp`, `/private/var/tmp`, `/private/var/log`, `/private/var/folders` - `/private/var/db/diagnostics`, `/private/var/db/DiagnosticPipeline`, `/private/var/db/powerlog`, `/private/var/db/reportmemoryexception` -**Code:** `lib/core/file_ops.sh:60-78` +See `lib/core/file_ops.sh:60-78`. -#### Layer 3: Symlink Detection +When running with sudo, `safe_sudo_recursive_delete()` also checks for symlinks. Refuses to follow symlinks pointing to system files. -For privileged operations, pre-flight checks prevent symlink-based attacks: +## Cleanup Rules -- Detects symlinks from cache folders pointing to system files. -- Refuses recursive deletion of symbolic links in sudo mode. -- Validates real path vs. symlink target. +**Orphan detection** at `lib/clean/apps.sh:orphan_detection()`: -**Code:** `lib/core/file_ops.sh:safe_sudo_recursive_delete()` +App data is only considered orphaned if the app itself is gone from all three locations: `/Applications`, `~/Applications`, `/System/Applications`. On top of that, the data must be untouched for at least 60 days. Adobe, Microsoft, and Google stuff is whitelisted regardless. -#### Layer 4: Permission Management +**Uninstall matching** at `lib/clean/apps.sh:uninstall_app()`: -When running with `sudo`: +App names need at least 3 characters. Otherwise "Go" would match "Google" and that's bad. Fuzzy matching is off. Receipt scans only look under `/Applications` and `/Library/Application Support`, not in shared places like `/Library/Frameworks`. -- Auto-corrects ownership back to user (`chown -R`). -- Restricts operations to the user's home directory. -- Enforces multiple validation checkpoints. +**Dev tools:** -### Interactive Analyzer (Go) +Cache dirs like `~/.cargo/registry/cache` or `~/.gradle/caches` get cleaned. But `~/.cargo/bin`, `~/.mix/archives`, `~/.rustup` toolchains, `~/.stack/programs` stay untouched. -The analyzer (`mo analyze`) uses a distinct security model: +**LaunchAgent removal:** -- Runs with standard user permissions only. -- Respects macOS System Integrity Protection (SIP). -- **Two-Key Confirmation:** Deletion requires ⌫ (Delete) to enter confirmation mode, then Enter to confirm. Prevents accidental double-press of the same key. -- **Trash Instead of Delete:** Files are moved to macOS Trash using Finder's native API, allowing easy recovery if needed. -- OS-level enforcement (cannot delete `/System` due to Read-Only Volume). +Only removed when uninstalling the app that owns them. All `com.apple.*` items are skipped. Services get stopped via `launchctl` first. Generic names like Music, Notes, Photos are excluded from the search. -**Code:** `cmd/analyze/*.go` +See `lib/core/app_protection.sh:find_app_files()`. ---- +## Protected Categories -## Safety Mechanisms +| Category | What's protected | +| -------- | ---------------- | +| System | Control Center, System Settings, TCC, `/Library/Updates`, Spotlight | +| VPN/Proxy | Shadowsocks, V2Ray, Tailscale, Clash | +| AI | Cursor, Claude, ChatGPT, Ollama, LM Studio | +| Time Machine | Checks if backup is running. If status unclear, skips cleanup. | +| Startup | `com.apple.*` LaunchAgents/Daemons always skipped | -### Conservative Cleaning Logic +See `lib/core/app_protection.sh:is_critical_system_component()`. -#### The "60-Day Rule" for Orphaned Data +## Analyzer -| Step | Verification | Criterion | -|------|--------------|-----------| -| 1. App Check | All installation locations | Must be missing from `/Applications`, `~/Applications`, `/System/Applications` | -| 2. Dormancy | Modification timestamps | Untouched for ≥60 days | -| 3. Vendor Whitelist | Cross-reference database | Adobe, Microsoft, and Google resources are protected | +`mo analyze` runs differently: -**Code:** `lib/clean/apps.sh:orphan_detection()` +- Standard user permissions, no sudo +- Respects SIP +- Two keys to delete: press ⌫ first, then Enter. Hard to delete by accident. +- Files go to Trash via Finder API, not rm -#### Developer Tool Ecosystems (Consolidated) +Code at `cmd/analyze/*.go`. -Support for 20+ languages (Rust, Go, Node, Python, JVM, Mobile, Elixir, Haskell, OCaml, etc.) with strict safety checks: +## Timeouts -- **Global Optimization:** The core `safe_clean` function now intelligently checks parent directories before attempting wildcard cleanups, eliminating overhead for missing tools across the entire system. -- **Safe Targets:** Only volatile caches are cleaned (e.g., `~/.cargo/registry/cache`, `~/.gradle/caches`). -- **Protected Paths:** Critical directories like `~/.cargo/bin`, `~/.mix/archives`, `~/.rustup` toolchains, and `~/.stack/programs` are explicitly **excluded**. +| Operation | Timeout | Why | +| --------- | ------- | --- | +| Network volume check | 5s | NFS/SMB/AFP can hang forever | +| App bundle search | 10s | mdfind sometimes stalls | +| SQLite vacuum | 20s | Skip if Mail/Safari/Messages is open | +| dyld cache rebuild | 180s | Skip if done in last 24h | -#### Active Uninstallation Heuristics +See `lib/core/base.sh:run_with_timeout()`. -For user-selected app removal: +## User Config -- **Sanitized Name Matching:** "Visual Studio Code" → `VisualStudioCode`, `.vscode` -- **Safety Limit:** 3-char minimum (prevents "Go" matching "Google") -- **Disabled:** Fuzzy matching and wildcard expansion for short names. -- **User Confirmation:** Required before deletion. -- **Receipt Scans:** BOM-derived files are restricted to app-specific prefixes (e.g., `/Applications`, `/Library/Application Support`). Shared directories like `/Library/Frameworks` are **excluded** to prevent collateral damage. - -**Code:** `lib/clean/apps.sh:uninstall_app()` - -#### System Protection Policies - -| Protected Category | Scope | Reason | -|--------------------|-------|--------| -| System Integrity Protection | `/Library/Updates`, `/System/*` | Respects macOS Read-Only Volume | -| Spotlight & System UI | `~/Library/Metadata/CoreSpotlight` | Prevents UI corruption | -| System Components | Control Center, System Settings, TCC | Centralized detection via `is_critical_system_component()` | -| Time Machine | Local snapshots, backups | Runtime activity detection (backup running, snapshots mounted), fails safe if status indeterminate | -| VPN & Proxy | Shadowsocks, V2Ray, Tailscale, Clash | Protects network configs | -| AI & LLM Tools | Cursor, Claude, ChatGPT, Ollama, LM Studio | Protects models, tokens, and sessions | -| Startup Items | `com.apple.*` LaunchAgents/Daemons | System items unconditionally skipped | - -**LaunchAgent/LaunchDaemon Cleanup During Uninstallation:** - -When users uninstall applications via `mo uninstall`, Mole automatically removes associated LaunchAgent and LaunchDaemon plists: - -- Scans `~/Library/LaunchAgents`, `~/Library/LaunchDaemons`, `/Library/LaunchAgents`, `/Library/LaunchDaemons` -- Matches both exact bundle ID (`com.example.app.plist`) and app name patterns (`*AppName*.plist`) -- Skips all `com.apple.*` system items via `should_protect_path()` validation -- Unloads services via `launchctl` before deletion (via `stop_launch_services()`) -- **Safer than orphan detection:** Only removes plists when the associated app is explicitly being uninstalled -- Prevents accumulation of orphaned startup items that persist after app removal -- **Common word exclusion:** LaunchAgent name searches exclude generic terms (Music, Notes, Photos, etc.) to prevent false positives -- **Bundle ID validation:** `stop_launch_services()` validates reverse-DNS format before find patterns - -**Code:** `lib/core/app_protection.sh:find_app_files()`, `lib/uninstall/batch.sh:stop_launch_services()` - -### Crash Safety & Atomic Operations - -| Operation | Safety Mechanism | Recovery Behavior | -|-----------|------------------|-------------------| -| Network Interface Reset | Atomic execution blocks | Wi-Fi/AirDrop restored to pre-operation state | -| Swap Clearing | Daemon restart | `dynamic_pager` handles recovery safely | -| Volume Scanning | Timeout + filesystem check | Auto-skip unresponsive NFS/SMB/AFP mounts | -| Homebrew Cache | Pre-flight size check | Skip if <50MB (avoids long delays) | -| Network Volume Check | `diskutil info` with timeout | Prevents hangs on slow/dead mounts | -| SQLite Vacuum | App-running check + 20s timeout | Skips if Mail/Safari/Messages active | -| dyld Cache Update | 24-hour freshness check + 180s timeout | Skips if recently updated | -| App Bundle Search | 10s timeout on mdfind | Fallback to standard paths | - -**Timeout Example:** +Put paths in `~/.config/mole/whitelist`, one per line: ```bash -run_with_timeout 5 diskutil info "$mount_point" || skip_volume +# exact matches only +/Users/me/important-cache +~/Library/Application Support/MyApp ``` -**Code:** `lib/core/base.sh:run_with_timeout()`, `lib/optimize/*.sh` +These paths are protected from all operations. ---- +Run `mo clean --dry-run` or `mo optimize --dry-run` to preview what would happen without actually doing it. -## User Controls +## Testing -### Dry-Run Mode +| Area | Coverage | +| ---- | -------- | +| File ops | 95% | +| Cleaning | 87% | +| Optimize | 82% | +| System | 90% | +| Security | 100% | -**Command:** `mo clean --dry-run` | `mo optimize --dry-run` - -**Behavior:** - -- Simulates the entire operation without modifying a single file. -- Lists every file/directory that **would** be deleted. -- Calculates total space that **would** be freed. -- **Zero risk** - no actual deletion commands are executed. - -### Custom Whitelists - -**File:** `~/.config/mole/whitelist` - -**Format:** +180+ test cases total, about 88% coverage. ```bash -# One path per line - exact matches only -/Users/username/important-cache -~/Library/Application Support/CriticalApp +bats tests/ # run all +bats tests/security.bats # security only ``` -- Paths are **unconditionally protected**. -- Applies to all operations (clean, optimize, uninstall). -- Supports absolute paths and `~` expansion. - -**Code:** `lib/core/file_ops.sh:is_whitelisted()` - -### Interactive Confirmations - -We mandate confirmation for: - -- Uninstalling system-scope applications. -- Removing large data directories (>1GB). -- Deleting items from shared vendor folders. - ---- - -## Testing & Compliance - -### Test Coverage - -Mole uses **BATS (Bash Automated Testing System)** for automated testing. - -| Test Category | Coverage | Key Tests | -|---------------|----------|-----------| -| Core File Operations | 95% | Path validation, symlink detection, permissions | -| Cleaning Logic | 87% | Orphan detection, 60-day rule, vendor whitelist | -| Optimization | 82% | Cache cleanup, timeouts | -| System Maintenance | 90% | Time Machine, network volumes, crash recovery | -| Security Controls | 100% | Path traversal, command injection, symlinks | - -**Total:** 180+ tests | **Overall Coverage:** ~88% - -**Test Execution:** - -```bash -bats tests/ # Run all tests -bats tests/security.bats # Run specific suite -``` - -### Standards Compliance - -| Standard | Implementation | -|----------|----------------| -| OWASP Secure Coding | Input validation, least privilege, defense-in-depth | -| CWE-22 (Path Traversal) | Enhanced detection: rejects `/../` components, safely handles `..` in directory names | -| CWE-78 (Command Injection) | Control character filtering | -| CWE-59 (Link Following) | Symlink detection before privileged operations | -| Apple File System Guidelines | Respects SIP, Read-Only Volumes, TCC | - -### Security Development Lifecycle - -- **Static Analysis:** `shellcheck` runs on all shell scripts. -- **Code Review:** All changes are manually reviewed by maintainers. -- **Dependency Scanning:** Minimal external dependencies, all carefully vetted. - -### Known Limitations - -| Limitation | Impact | Mitigation | -|------------|--------|------------| -| Requires `sudo` for system caches | Initial friction | Clear documentation explaining why | -| 60-day rule may delay cleanup | Some orphans remain longer | Manual `mo uninstall` is always available | -| No undo functionality | Deleted files are unrecoverable | Dry-run mode and warnings are clear | -| English-only name matching | May miss non-English apps | Fallback to Bundle ID matching | - -**Intentionally Out of Scope (Safety):** - -- Automatic deletion of user documents/media. -- Encryption key stores or password managers. -- System configuration files (`/etc/*`). -- Browser history or cookies. -- Git repository cleanup. - ---- +CI runs shellcheck and go vet on every push. ## Dependencies -### System Binaries +System binaries used, all SIP protected: -Mole relies on standard, SIP-protected macOS system binaries: +| Binary | For | +| ------ | --- | +| `plutil` | plist validation | +| `tmutil` | Time Machine | +| `dscacheutil` | cache rebuild | +| `diskutil` | volume info | -| Binary | Purpose | Fallback | -|--------|---------|----------| -| `plutil` | Validate `.plist` integrity | Skip invalid plists | -| `tmutil` | Time Machine interaction | Skip TM cleanup | -| `dscacheutil` | System cache rebuilding | Optional optimization | -| `diskutil` | Volume information | Skip network volumes | +Go libs in analyze-go: -### Go Dependencies (Interactive Tools) +| Lib | Version | License | +| --- | ------- | ------- | +| `bubbletea` | v0.23+ | MIT | +| `lipgloss` | v0.6+ | MIT | +| `gopsutil` | v3.22+ | BSD-3 | +| `xxhash` | v2.2+ | BSD-2 | -The compiled Go binary (`analyze-go`) includes: +Versions are pinned. No CVEs. Binaries built via GitHub Actions. -| Library | Version | Purpose | License | -|---------|---------|---------|---------| -| `bubbletea` | v0.23+ | TUI framework | MIT | -| `lipgloss` | v0.6+ | Terminal styling | MIT | -| `gopsutil` | v3.22+ | System metrics | BSD-3 | -| `xxhash` | v2.2+ | Fast hashing | BSD-2 | +## Limitations -**Supply Chain Security:** +| What | Impact | Workaround | +| ---- | ------ | ---------- | +| Needs sudo for system caches | Annoying first time | Docs explain why | +| 60-day wait for orphans | Some junk stays longer | Use `mo uninstall` manually | +| No undo | Gone is gone | Use dry-run first | +| English names only | Might miss localized apps | Falls back to bundle ID | -- All dependencies are pinned to specific versions. -- Regular security audits. -- No transitive dependencies with known CVEs. -- **Automated Releases**: Binaries are compiled and signed via GitHub Actions. -- **Source Only**: The repository contains no pre-compiled binaries. +**Won't touch:** ---- - -**Our Commitment:** This document certifies that Mole implements industry-standard defensive programming practices and strictly adheres to macOS security guidelines. We prioritize system stability and data integrity above all else. - -*For security concerns or vulnerability reports, please open an issue or contact the maintainers directly.* +- Your documents or media +- Password managers or keychains +- Files under `/etc` +- Browser history/cookies +- Git repos From fca0fe1b15027a19ff0858c4223868f20799dfbd Mon Sep 17 00:00:00 2001 From: tw93 Date: Tue, 27 Jan 2026 10:38:53 +0800 Subject: [PATCH 87/91] update readme --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0fd48c2..35fa33b 100644 --- a/README.md +++ b/README.md @@ -259,21 +259,21 @@ Mole automatically detects your terminal, or set `MO_LAUNCHER_APP=` to ove ## Community Love -Mole wouldn't be possible without these amazing contributors. They've built countless features that make Mole what it is today. Go follow them! ❤️ +Big thanks to all contributors who helped build Mole. Go follow them! ❤️ -Join thousands of users worldwide who trust Mole to keep their Macs clean and optimized. +Users from around the world. Community feedback on Mole ## Support -- If Mole saved you disk space, consider starring the repo or [sharing it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Mole&text=Mole%20-%20Deep%20clean%20and%20optimize%20your%20Mac.) with friends. -- Have ideas or fixes? Check our [Contributing Guide](CONTRIBUTING.md), then open an issue or PR to help shape Mole's future. -- Love Mole? Buy Tw93 an ice-cold Coke to keep the project alive and kicking! 🥤 +- If Mole helped you, star the repo or [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Mole&text=Mole%20-%20Deep%20clean%20and%20optimize%20your%20Mac.) with friends. +- Got ideas or found bugs? Check the [Contributing Guide](CONTRIBUTING.md) and open an issue or PR. +- Like Mole? Buy Tw93 a Coke to support the project! 🥤
Friends who bought me Coke From 8c5d529171ba5f0a99eae8948b4d6f2ad895320a Mon Sep 17 00:00:00 2001 From: tw93 Date: Tue, 27 Jan 2026 10:41:18 +0800 Subject: [PATCH 88/91] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 35fa33b..d9d51d5 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ Big thanks to all contributors who helped build Mole. Go follow them! ❤️ -Users from around the world. +Real feedback from users who shared Mole on X. Community feedback on Mole From 1cf772b02c58f3eec1758cfb9181c11ed0d5dc1f Mon Sep 17 00:00:00 2001 From: tw93 Date: Tue, 27 Jan 2026 10:41:38 +0800 Subject: [PATCH 89/91] update readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d9d51d5..a562026 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,8 @@ Big thanks to all contributors who helped build Mole. Go follow them! ❤️ +--- + Real feedback from users who shared Mole on X. Community feedback on Mole From 9bb255238d82dc44cac999f822ff5a8c397796c2 Mon Sep 17 00:00:00 2001 From: tw93 Date: Tue, 27 Jan 2026 10:42:58 +0800 Subject: [PATCH 90/91] update readme --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index a562026..b2eb373 100644 --- a/README.md +++ b/README.md @@ -265,8 +265,7 @@ Big thanks to all contributors who helped build Mole. Go follow them! ❤️ ---- - +
Real feedback from users who shared Mole on X. Community feedback on Mole From 92de42981774e88ed176b7ea7592b6d5606e70e7 Mon Sep 17 00:00:00 2001 From: tw93 Date: Tue, 27 Jan 2026 10:43:33 +0800 Subject: [PATCH 91/91] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b2eb373..6264759 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ Big thanks to all contributors who helped build Mole. Go follow them! ❤️ -
+

Real feedback from users who shared Mole on X. Community feedback on Mole