diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index 13cc837..fd61134 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -79,15 +79,10 @@ clean_ds_store_tree() { # Clean data for uninstalled apps (caches/logs/states older than 60 days) # Protects system apps, major vendors, scans /Applications+running processes # Max 100 items/pattern, 2s du timeout. Env: ORPHAN_AGE_THRESHOLD, DRY_RUN -clean_orphaned_app_data() { - # Quick permission check - if we can't access Library folders, skip - if ! ls "$HOME/Library/Caches" > /dev/null 2>&1; then - echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped: No permission to access Library folders" - return 0 - fi - - # Build list of installed/active apps - local installed_bundles=$(create_temp_file) +# Scan system for installed application bundle IDs +# Usage: scan_installed_apps "output_file" +scan_installed_apps() { + local installed_bundles="$1" # Scan all Applications directories local -a app_dirs=( @@ -169,8 +164,8 @@ clean_orphaned_app_data() { ( run_with_timeout 5 find ~/Library/LaunchAgents /Library/LaunchAgents \ - -name "*.plist" -type f 2> /dev/null | - xargs -I {} basename {} .plist > "$scan_tmp_dir/agents.txt" 2> /dev/null || true + -name "*.plist" -type f 2> /dev/null | + xargs -I {} basename {} .plist > "$scan_tmp_dir/agents.txt" 2> /dev/null || true ) & pids+=($!) @@ -193,56 +188,71 @@ clean_orphaned_app_data() { local app_count=$(wc -l < "$installed_bundles" 2> /dev/null | tr -d ' ') echo -e " ${GREEN}${ICON_SUCCESS}${NC} Found $app_count active/installed apps" +} + +# Check if bundle is orphaned +# Usage: is_bundle_orphaned "bundle_id" "directory_path" "installed_bundles_file" +is_bundle_orphaned() { + local bundle_id="$1" + local directory_path="$2" + local installed_bundles="$3" + + # Skip system-critical and protected apps + if should_protect_data "$bundle_id"; then + return 1 + fi + + # Check if app exists in our scan + if grep -Fxq "$bundle_id" "$installed_bundles" 2> /dev/null; then + return 1 + fi + + # Check against centralized protected patterns (app_protection.sh) + if should_protect_data "$bundle_id"; then + return 1 + fi + + + # Extra check for specific system bundles not covered by patterns + case "$bundle_id" in + loginwindow | dock | systempreferences | finder | safari) + return 1 + ;; + esac + + # Check file age - only clean if 60+ days inactive + # Use existing logic + if [[ -e "$directory_path" ]]; then + local last_modified_epoch=$(get_file_mtime "$directory_path") + local current_epoch=$(date +%s) + local days_since_modified=$(((current_epoch - last_modified_epoch) / 86400)) + + if [[ $days_since_modified -lt ${ORPHAN_AGE_THRESHOLD:-60} ]]; then + return 1 + fi + fi + + return 0 +} + +# Clean data for uninstalled apps (caches/logs/states older than 60 days) +# Protects system apps, major vendors, scans /Applications+running processes +# Max 100 items/pattern, 2s du timeout. Env: ORPHAN_AGE_THRESHOLD, DRY_RUN +clean_orphaned_app_data() { + # Quick permission check - if we can't access Library folders, skip + if ! ls "$HOME/Library/Caches" > /dev/null 2>&1; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped: No permission to access Library folders" + return 0 + fi + + # Build list of installed/active apps + local installed_bundles=$(create_temp_file) + scan_installed_apps "$installed_bundles" # Track statistics local orphaned_count=0 local total_orphaned_kb=0 - # Check if bundle is orphaned - conservative approach - is_orphaned() { - local bundle_id="$1" - local directory_path="$2" - - # Skip system-critical and protected apps - if should_protect_data "$bundle_id"; then - return 1 - fi - - # Check if app exists in our scan - if grep -Fxq "$bundle_id" "$installed_bundles" 2> /dev/null; then - return 1 - fi - - # Extra check for system bundles - case "$bundle_id" in - com.apple.* | loginwindow | dock | systempreferences | finder | safari) - return 1 - ;; - esac - - # Skip major vendors - case "$bundle_id" in - com.adobe.* | com.microsoft.* | com.google.* | org.mozilla.* | com.jetbrains.* | com.docker.*) - return 1 - ;; - esac - - # Check file age - only clean if 60+ days inactive - # Use modification time (mtime) instead of access time (atime) - # because macOS disables atime updates by default for performance - if [[ -e "$directory_path" ]]; then - local last_modified_epoch=$(get_file_mtime "$directory_path") - local current_epoch=$(date +%s) - local days_since_modified=$(((current_epoch - last_modified_epoch) / 86400)) - - if [[ $days_since_modified -lt ${ORPHAN_AGE_THRESHOLD:-60} ]]; then - return 1 - fi - fi - - return 0 - } - # Unified orphaned resource scanner (caches, logs, states, webkit, HTTP, cookies) MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning orphaned app resources..." @@ -300,7 +310,7 @@ clean_orphaned_app_data() { bundle_id="${bundle_id%.savedState}" bundle_id="${bundle_id%.binarycookies}" - if is_orphaned "$bundle_id" "$match"; then + if is_bundle_orphaned "$bundle_id" "$match" "$installed_bundles"; then # Use timeout to prevent du from hanging on network mounts or problematic paths local size_kb size_kb=$(get_path_size_kb "$match") diff --git a/lib/clean/brew.sh b/lib/clean/brew.sh index fac8084..64e53a2 100644 --- a/lib/clean/brew.sh +++ b/lib/clean/brew.sh @@ -38,26 +38,36 @@ clean_orphaned_casks() { rm -f "$cask_cache" 2> /dev/null || true true > "$cask_cache" - while IFS= read -r cask; do + while IFS= read -r cask; do # Get app path from cask info with timeout protection (expensive call, hence caching) local cask_info cask_info=$(run_with_timeout 10 brew info --cask "$cask" 2> /dev/null || true) + # SAFETY: Skip if cask contains non-App artifacts (Screen Savers, Plugins, etc.) + # This prevents accidental deletion of casks that don't primarily install to /Applications + if echo "$cask_info" | grep -qE '\((Screen Saver|Preference Pane|Audio Unit|VST|VST3|Component|QuickLook|Spotlight|Artifact)\)'; then + continue + fi + # Extract app name from "AppName.app (App)" format in cask info output local app_name app_name=$(echo "$cask_info" | grep -E '\.app \(App\)' | head -1 | sed -E 's/^[[:space:]]*//' | sed -E 's/ \(App\).*//' || true) - # Skip if no app artifact (might be a utility package like fonts) + # Skip if no app artifact (might be a utility package like fonts or just drivers) [[ -z "$app_name" ]] && continue # Save to cache for future runs echo "$cask|$app_name" >> "$cask_cache" - # Check if app exists in /Applications - [[ ! -e "/Applications/$app_name" ]] && orphaned_casks+=("$cask") + # Check if app exists into common locations + # We must check both /Applications and ~/Applications + if [[ ! -e "/Applications/$app_name" ]] && [[ ! -e "$HOME/Applications/$app_name" ]]; then + orphaned_casks+=("$cask") + fi done < <(brew list --cask 2> /dev/null || true) fi + # Remove orphaned casks if found and sudo session is still valid if [[ ${#orphaned_casks[@]} -gt 0 ]]; then # Check if sudo session is still valid (without prompting) diff --git a/lib/clean/system.sh b/lib/clean/system.sh index 4606775..924eaf3 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -52,10 +52,7 @@ clean_deep_system() { fi fi - # Clean orphaned cask records (delegated to clean_brew module) - # DISABLED: This feature triggers password prompts and provides minimal benefit - # Users can manually run: brew list --cask && brew uninstall --cask - # clean_orphaned_casks + # Clean macOS Install Data (system upgrade leftovers) # Only remove if older than 30 days to ensure system stability diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 88da1af..690694d 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -193,11 +193,19 @@ clean_application_support_logs() { # Skip system and protected apps (case-insensitive) local app_name_lower app_name_lower=$(echo "$app_name" | tr '[:upper:]' '[:lower:]') - case "$app_name_lower" in - com.apple.* | adobe* | jetbrains* | 1password | claude | *clashx* | *clash* | mihomo* | *surge* | iterm* | warp* | kitty* | alacritty* | wezterm* | ghostty*) - continue - ;; - esac + # Use centralized protection logic from app_protection.sh + # Check against System Critical and Data Protected bundles + local is_protected=false + + # Check if directory name matches any protected pattern + # We check both exact name and lowercase version against the patterns + if should_protect_data "$app_name"; then + is_protected=true + elif should_protect_data "$app_name_lower"; then + is_protected=true + fi + + [[ "$is_protected" == "true" ]] && continue # Clean log directories - simple direct removal without deep scanning [[ -d "$app_dir/log" ]] && safe_clean "$app_dir/log"/* "App logs: $app_name" diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 35f7ae2..8f23dd4 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -685,6 +685,107 @@ find_app_system_files() { if [[ ${#system_files[@]} -gt 0 ]]; then printf '%s\n' "${system_files[@]}" fi + + # Find files from receipts (Deep Scan) + find_app_receipt_files "$bundle_id" +} + +# Find files from installation receipts (Bom files) +find_app_receipt_files() { + local bundle_id="$1" + + # Skip if no bundle ID + [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0 + + local -a receipt_files=() + local -a bom_files=() + + # Find receipts matching the bundle ID + # Usually in /var/db/receipts/ + if [[ -d /private/var/db/receipts ]]; then + while IFS= read -r -d '' bom; do + bom_files+=("$bom") + done < <(find /private/var/db/receipts -name "${bundle_id}*.bom" -print0 2> /dev/null) + fi + + for bom_file in "${bom_files[@]}"; do + [[ ! -f "$bom_file" ]] && continue + + # Parse bom file + # lsbom -f: file paths only + # -s: suppress output (convert to text) + local bom_content + bom_content=$(lsbom -f -s "$bom_file" 2> /dev/null) + + while IFS= read -r file_path; do + # Standardize path (remove leading dot) + local clean_path="${file_path#.}" + + # Ensure it starts with / + if [[ "$clean_path" != /* ]]; then + clean_path="/$clean_path" + fi + + # ------------------------------------------------------------------------ + # SAFETY FILTER: Only allow specific removal paths + # ------------------------------------------------------------------------ + local is_safe=false + + # Whitelisted prefixes + case "$clean_path" in + /Applications/*) is_safe=true ;; + /Users/*) is_safe=true ;; + /usr/local/*) is_safe=true ;; + /opt/*) is_safe=true ;; + /Library/*) + # Filter sub-paths in /Library to avoid system damage + # Allow safely: Application Support, Caches, Logs, Preferences + case "$clean_path" in + /Library/Application\ Support/*) is_safe=true ;; + /Library/Caches/*) is_safe=true ;; + /Library/Logs/*) is_safe=true ;; + /Library/Preferences/*) is_safe=true ;; + /Library/PrivilegedHelperTools/*) is_safe=true ;; + /Library/LaunchAgents/*) is_safe=true ;; + /Library/LaunchDaemons/*) is_safe=true ;; + /Library/Internet\ Plug-Ins/*) is_safe=true ;; + /Library/Audio/Plug-Ins/*) is_safe=true ;; + /Library/Extensions/*) is_safe=false ;; # Default unsafe + *) is_safe=false ;; + esac + ;; + esac + + # Hard blocks + case "$clean_path" in + /System/*|/usr/bin/*|/usr/lib/*|/bin/*|/sbin/*) is_safe=false ;; + esac + + if [[ "$is_safe" == "true" && -e "$clean_path" ]]; then + # Only valid files + # Don't delete directories if they are non-empty parents? + # lsbom lists directories too. + # If we return a directory, `safe_remove` logic handles it. + # `uninstall.sh` uses `remove_file_list`. + # If `lsbom` lists `/Applications` (it shouldn't, only contents), we must be careful. + # `lsbom` usually lists `./Applications/MyApp.app`. + # If it lists `./Applications`, we must skip it. + + # Extra check: path must be deep enough? + # If path is just "/Applications", skip. + if [[ "$clean_path" == "/Applications" || "$clean_path" == "/Library" || "$clean_path" == "/usr/local" ]]; then + continue + fi + + receipt_files+=("$clean_path") + fi + + done <<< "$bom_content" + done + + if [[ ${#receipt_files[@]} -gt 0 ]]; then + printf '%s\n' "${receipt_files[@]}" + fi } # Force quit an application diff --git a/lib/core/common.sh b/lib/core/common.sh index 8da0cbb..38beb4c 100755 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -15,6 +15,7 @@ _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Load core modules in dependency order source "$_MOLE_CORE_DIR/base.sh" source "$_MOLE_CORE_DIR/log.sh" + source "$_MOLE_CORE_DIR/timeout.sh" source "$_MOLE_CORE_DIR/file_ops.sh" source "$_MOLE_CORE_DIR/ui.sh" diff --git a/lib/core/log.sh b/lib/core/log.sh index 708a83b..59ca598 100644 --- a/lib/core/log.sh +++ b/lib/core/log.sh @@ -22,6 +22,7 @@ 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 # Ensure log directory exists @@ -53,28 +54,44 @@ rotate_log_once() { # Args: $1 - message log_info() { echo -e "${BLUE}$1${NC}" - echo "[$(date '+%Y-%m-%d %H:%M:%S')] INFO: $1" >> "$LOG_FILE" 2> /dev/null || true + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + 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 + fi } # Log success message # Args: $1 - message log_success() { echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1" - echo "[$(date '+%Y-%m-%d %H:%M:%S')] SUCCESS: $1" >> "$LOG_FILE" 2> /dev/null || true + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + 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 + fi } # Log warning message # Args: $1 - message log_warning() { echo -e "${YELLOW}$1${NC}" - echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARNING: $1" >> "$LOG_FILE" 2> /dev/null || true + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + 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 + fi } # Log error message # Args: $1 - message log_error() { echo -e "${RED}${ICON_ERROR}${NC} $1" >&2 - echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + 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 + fi } # Debug logging - only shown when MO_DEBUG=1 @@ -82,9 +99,46 @@ 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 fi } +# Log system information for debugging +log_system_info() { + # Only allow once per session + [[ -n "${MOLE_SYS_INFO_LOGGED:-}" ]] && return 0 + export MOLE_SYS_INFO_LOGGED=1 + + # Reset debug log file for this new session + : > "$DEBUG_LOG_FILE" + + # Start block in debug log file + { + echo "----------------------------------------------------------------------" + 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))" + fi + echo "Shell: ${SHELL:-unknown} (${TERM:-unknown})" + + # Check sudo status non-interactively + 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 + + # Notification to stderr + echo -e "${GRAY}[DEBUG] Debug logging enabled. Session log: $DEBUG_LOG_FILE${NC}" >&2 +} + # ============================================================================ # Command Execution Wrappers # ============================================================================ @@ -100,9 +154,17 @@ run_silent() { # Returns: command exit code run_logged() { local cmd="$1" - if ! "$@" 2>&1 | tee -a "$LOG_FILE" > /dev/null; then - log_warning "Command failed: $cmd" - return 1 + # 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 + log_warning "Command failed: $cmd" + return 1 + fi + else + if ! "$@" 2>&1 | tee -a "$LOG_FILE" > /dev/null; then + log_warning "Command failed: $cmd" + return 1 + fi fi return 0 } @@ -143,6 +205,11 @@ print_summary_block() { echo -e "${detail}" done echo "$divider" + + # If debug mode is on, remind user about the log file location + if [[ "${MO_DEBUG:-}" == "1" ]]; then + echo -e "${GRAY}Debug session log saved to:${NC} ${DEBUG_LOG_FILE}" + fi } # ============================================================================ @@ -151,3 +218,8 @@ print_summary_block() { # Perform log rotation check on module load rotate_log_once + +# If debug mode is enabled, log system info immediately +if [[ "${MO_DEBUG:-}" == "1" ]]; then + log_system_info +fi