From 6036b4606caa41ae0c27def61734aa312043574f Mon Sep 17 00:00:00 2001 From: Tw93 Date: Wed, 28 Jan 2026 03:26:51 +0000 Subject: [PATCH 01/21] chore: auto format code --- cmd/analyze/heap_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/analyze/heap_test.go b/cmd/analyze/heap_test.go index 75076c8..a31190f 100644 --- a/cmd/analyze/heap_test.go +++ b/cmd/analyze/heap_test.go @@ -150,4 +150,3 @@ func TestLargeFileHeap(t *testing.T) { } }) } - From 61a3238f195ed47e8b90c62a9927d0601e8e3d92 Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 28 Jan 2026 11:43:39 +0800 Subject: [PATCH 02/21] fix(scan): prevent hang on cyclic symlinks (#378, #379) --- cmd/analyze/scanner.go | 2 +- lib/core/file_ops.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index 5a0cde3..539fdb0 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -584,7 +584,7 @@ func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, ctx, cancel := context.WithTimeout(context.Background(), duTimeout) defer cancel() - cmd := exec.CommandContext(ctx, "du", "-sk", target) + cmd := exec.CommandContext(ctx, "du", "-skP", target) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 33a9126..126fcf0 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -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 -skP "$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 From 6f0255c56eb05b6623c5b556bb314d44b596ffda Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 28 Jan 2026 19:06:52 +0800 Subject: [PATCH 03/21] fix(uninstall): enhance app leftover detection with naming variants (#377) --- lib/core/app_protection.sh | 57 ++++++++++++++-- tests/uninstall_naming_variants.bats | 97 ++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 tests/uninstall_naming_variants.bats diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 446a478..9f0ea6d 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -670,9 +670,26 @@ find_app_files() { local -a files_to_clean=() - # Normalize app name for matching - local nospace_name="${app_name// /}" - local underscore_name="${app_name// /_}" + # Normalize app name for matching - generate all common naming variants + # Apps use inconsistent naming: "Maestro Studio" vs "maestro-studio" vs "MaestroStudio" + # Note: Using tr for lowercase conversion (Bash 3.2 compatible, no ${var,,} support) + local nospace_name="${app_name// /}" # "Maestro Studio" -> "MaestroStudio" + local underscore_name="${app_name// /_}" # "Maestro Studio" -> "Maestro_Studio" + local hyphen_name="${app_name// /-}" # "Maestro Studio" -> "Maestro-Studio" + local lowercase_name=$(echo "$app_name" | tr '[:upper:]' '[:lower:]') # "Zed Nightly" -> "zed nightly" + local lowercase_nospace=$(echo "$nospace_name" | tr '[:upper:]' '[:lower:]') # "MaestroStudio" -> "maestrostudio" + local lowercase_hyphen=$(echo "$hyphen_name" | tr '[:upper:]' '[:lower:]') # "Maestro-Studio" -> "maestro-studio" + local lowercase_underscore=$(echo "$underscore_name" | tr '[:upper:]' '[:lower:]') # "Maestro_Studio" -> "maestro_studio" + + # Extract base name by removing common version/channel suffixes + # "Zed Nightly" -> "Zed", "Firefox Developer Edition" -> "Firefox" + local base_name="$app_name" + local version_suffixes="Nightly|Beta|Alpha|Dev|Canary|Preview|Insider|Edge|Stable|Release|RC|LTS" + version_suffixes+="|Developer Edition|Technology Preview" + if [[ "$app_name" =~ ^(.+)[[:space:]]+(${version_suffixes})$ ]]; then + base_name="${BASH_REMATCH[1]}" + fi + local base_lowercase=$(echo "$base_name" | tr '[:upper:]' '[:lower:]') # "Zed" -> "zed" # Standard path patterns for user-level files local -a user_patterns=( @@ -714,13 +731,35 @@ find_app_files() { "$HOME/.$app_name"rc ) - # Add sanitized name variants if unique enough + # Add all naming variants to cover inconsistent app directory naming + # Issue #377: Apps create directories with various naming conventions if [[ ${#app_name} -gt 3 && "$app_name" =~ [[:space:]] ]]; then user_patterns+=( + # Compound naming (MaestroStudio, Maestro_Studio, Maestro-Studio) "$HOME/Library/Application Support/$nospace_name" "$HOME/Library/Caches/$nospace_name" "$HOME/Library/Logs/$nospace_name" "$HOME/Library/Application Support/$underscore_name" + "$HOME/Library/Application Support/$hyphen_name" + # Lowercase variants (maestrostudio, maestro-studio, maestro_studio) + "$HOME/.config/$lowercase_nospace" + "$HOME/.config/$lowercase_hyphen" + "$HOME/.config/$lowercase_underscore" + "$HOME/.local/share/$lowercase_nospace" + "$HOME/.local/share/$lowercase_hyphen" + "$HOME/.local/share/$lowercase_underscore" + ) + fi + + # Add base name variants for versioned apps (e.g., "Zed Nightly" -> check for "zed") + if [[ "$base_name" != "$app_name" && ${#base_name} -gt 2 ]]; then + user_patterns+=( + "$HOME/Library/Application Support/$base_name" + "$HOME/Library/Caches/$base_name" + "$HOME/Library/Logs/$base_name" + "$HOME/.config/$base_lowercase" + "$HOME/.local/share/$base_lowercase" + "$HOME/.$base_lowercase" ) fi @@ -838,8 +877,11 @@ find_app_system_files() { local app_name="$2" local -a system_files=() - # Sanitized App Name (remove spaces) + # Generate all naming variants (same as find_app_files for consistency) local nospace_name="${app_name// /}" + local underscore_name="${app_name// /_}" + local hyphen_name="${app_name// /-}" + local lowercase_hyphen=$(echo "$hyphen_name" | tr '[:upper:]' '[:lower:]') # Standard system path patterns local -a system_patterns=( @@ -865,11 +907,16 @@ find_app_system_files() { "/Library/Caches/$app_name" ) + # Add all naming variants for apps with spaces in name if [[ ${#app_name} -gt 3 && "$app_name" =~ [[:space:]] ]]; then system_patterns+=( "/Library/Application Support/$nospace_name" "/Library/Caches/$nospace_name" "/Library/Logs/$nospace_name" + "/Library/Application Support/$underscore_name" + "/Library/Application Support/$hyphen_name" + "/Library/Caches/$hyphen_name" + "/Library/Caches/$lowercase_hyphen" ) fi diff --git a/tests/uninstall_naming_variants.bats b/tests/uninstall_naming_variants.bats new file mode 100644 index 0000000..49b0ad2 --- /dev/null +++ b/tests/uninstall_naming_variants.bats @@ -0,0 +1,97 @@ +#!/usr/bin/env bats +# Test naming variant detection for find_app_files (Issue #377) + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME + + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-naming.XXXXXX")" + export HOME + + source "$PROJECT_ROOT/lib/core/base.sh" + source "$PROJECT_ROOT/lib/core/log.sh" + source "$PROJECT_ROOT/lib/core/app_protection.sh" +} + +teardown_file() { + if [[ -d "$HOME" && "$HOME" =~ tmp-naming ]]; then + rm -rf "$HOME" + fi + export HOME="$ORIGINAL_HOME" +} + +setup() { + find "$HOME" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true + source "$PROJECT_ROOT/lib/core/base.sh" + source "$PROJECT_ROOT/lib/core/log.sh" + source "$PROJECT_ROOT/lib/core/app_protection.sh" +} + +@test "find_app_files detects lowercase-hyphen variant (maestro-studio)" { + mkdir -p "$HOME/.config/maestro-studio" + echo "test" > "$HOME/.config/maestro-studio/config.json" + + result=$(find_app_files "com.maestro.studio" "Maestro Studio") + + [[ "$result" =~ ".config/maestro-studio" ]] +} + +@test "find_app_files detects no-space variant (MaestroStudio)" { + mkdir -p "$HOME/Library/Application Support/MaestroStudio" + echo "test" > "$HOME/Library/Application Support/MaestroStudio/data.db" + + result=$(find_app_files "com.maestro.studio" "Maestro Studio") + + [[ "$result" =~ "Library/Application Support/MaestroStudio" ]] +} + +@test "find_app_files extracts base name from version suffix (Zed Nightly -> zed)" { + mkdir -p "$HOME/.config/zed" + mkdir -p "$HOME/Library/Application Support/Zed" + echo "test" > "$HOME/.config/zed/settings.json" + echo "test" > "$HOME/Library/Application Support/Zed/cache.db" + + result=$(find_app_files "dev.zed.Zed-Nightly" "Zed Nightly") + + [[ "$result" =~ ".config/zed" ]] + [[ "$result" =~ "Library/Application Support/Zed" ]] +} + +@test "find_app_files detects multiple naming variants simultaneously" { + mkdir -p "$HOME/.config/maestro-studio" + mkdir -p "$HOME/Library/Application Support/MaestroStudio" + mkdir -p "$HOME/Library/Application Support/Maestro-Studio" + mkdir -p "$HOME/.local/share/maestrostudio" + + echo "test" > "$HOME/.config/maestro-studio/config.json" + echo "test" > "$HOME/Library/Application Support/MaestroStudio/data.db" + echo "test" > "$HOME/Library/Application Support/Maestro-Studio/prefs.json" + echo "test" > "$HOME/.local/share/maestrostudio/cache.db" + + result=$(find_app_files "com.maestro.studio" "Maestro Studio") + + [[ "$result" =~ ".config/maestro-studio" ]] + [[ "$result" =~ "Library/Application Support/MaestroStudio" ]] + [[ "$result" =~ "Library/Application Support/Maestro-Studio" ]] + [[ "$result" =~ ".local/share/maestrostudio" ]] +} + +@test "find_app_files handles multi-word version suffix (Firefox Developer Edition)" { + mkdir -p "$HOME/.local/share/firefox" + echo "test" > "$HOME/.local/share/firefox/profiles.ini" + + result=$(find_app_files "org.mozilla.firefoxdeveloperedition" "Firefox Developer Edition") + + [[ "$result" =~ ".local/share/firefox" ]] +} + +@test "find_app_files does not match empty app name" { + mkdir -p "$HOME/Library/Application Support/test" + + result=$(find_app_files "com.test" "" 2>/dev/null || true) + + [[ ! "$result" =~ "Library/Application Support"$ ]] +} From 52ba523579703be726418d11239eb3a0888f6771 Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 28 Jan 2026 19:07:08 +0800 Subject: [PATCH 04/21] fix(install): clear quarantine attribute after downloading binaries (#381) --- install.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/install.sh b/install.sh index 6dc2cef..a75fe09 100755 --- a/install.sh +++ b/install.sh @@ -506,6 +506,7 @@ download_binary() { if curl -fsSL --connect-timeout 10 --max-time 60 -o "$target_path" "$url"; then if [[ -t 1 ]]; then stop_line_spinner; fi chmod +x "$target_path" + xattr -cr "$target_path" 2> /dev/null || true log_success "Downloaded ${binary_name} binary" else if [[ -t 1 ]]; then stop_line_spinner; fi From f815a5f28ce0ebaab091ebf60d5a45f243082f83 Mon Sep 17 00:00:00 2001 From: Dylan Joss Date: Wed, 28 Jan 2026 03:12:04 -0800 Subject: [PATCH 05/21] test: add tests for byte formatters and disk helpers (#382) Add unit tests for additional utility functions in `cmd/status/view_test.go`: - `humanBytes`: byte formatting with decimals and units - `humanBytesCompact`: compact byte formatting - `splitDisks`: separates internal/external disks - `diskLabel`: generates numbered disk labels Coverage for cmd/status improved from 6.9% to 8.4%. --- cmd/status/view_test.go | 167 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/cmd/status/view_test.go b/cmd/status/view_test.go index 6e9254d..f665952 100644 --- a/cmd/status/view_test.go +++ b/cmd/status/view_test.go @@ -112,3 +112,170 @@ func TestHumanBytesShort(t *testing.T) { }) } } + +func TestHumanBytes(t *testing.T) { + tests := []struct { + name string + input uint64 + want string + }{ + // Zero and small values. + {"zero", 0, "0 B"}, + {"one byte", 1, "1 B"}, + {"1023 bytes", 1023, "1023 B"}, + + // Kilobyte boundaries (uses > not >=). + {"exactly 1KB", 1 << 10, "1024 B"}, + {"just over 1KB", (1 << 10) + 1, "1.0 KB"}, + {"1.5KB", 1536, "1.5 KB"}, + + // Megabyte boundaries (uses > not >=). + {"exactly 1MB", 1 << 20, "1024.0 KB"}, + {"just over 1MB", (1 << 20) + 1, "1.0 MB"}, + {"500MB", 500 << 20, "500.0 MB"}, + + // Gigabyte boundaries (uses > not >=). + {"exactly 1GB", 1 << 30, "1024.0 MB"}, + {"just over 1GB", (1 << 30) + 1, "1.0 GB"}, + {"100GB", 100 << 30, "100.0 GB"}, + + // Terabyte boundaries (uses > not >=). + {"exactly 1TB", 1 << 40, "1024.0 GB"}, + {"just over 1TB", (1 << 40) + 1, "1.0 TB"}, + {"2TB", 2 << 40, "2.0 TB"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := humanBytes(tt.input) + if got != tt.want { + t.Errorf("humanBytes(%d) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestHumanBytesCompact(t *testing.T) { + tests := []struct { + name string + input uint64 + want string + }{ + // Zero and small values. + {"zero", 0, "0"}, + {"one byte", 1, "1"}, + {"1023 bytes", 1023, "1023"}, + + // Kilobyte boundaries (uses >= not >). + {"exactly 1KB", 1 << 10, "1.0K"}, + {"1.5KB", 1536, "1.5K"}, + + // Megabyte boundaries. + {"exactly 1MB", 1 << 20, "1.0M"}, + {"500MB", 500 << 20, "500.0M"}, + + // Gigabyte boundaries. + {"exactly 1GB", 1 << 30, "1.0G"}, + {"100GB", 100 << 30, "100.0G"}, + + // Terabyte boundaries. + {"exactly 1TB", 1 << 40, "1.0T"}, + {"2TB", 2 << 40, "2.0T"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := humanBytesCompact(tt.input) + if got != tt.want { + t.Errorf("humanBytesCompact(%d) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSplitDisks(t *testing.T) { + tests := []struct { + name string + disks []DiskStatus + wantInternal int + wantExternal int + }{ + { + name: "empty slice", + disks: []DiskStatus{}, + wantInternal: 0, + wantExternal: 0, + }, + { + name: "all internal", + disks: []DiskStatus{ + {Mount: "/", External: false}, + {Mount: "/System", External: false}, + }, + wantInternal: 2, + wantExternal: 0, + }, + { + name: "all external", + disks: []DiskStatus{ + {Mount: "/Volumes/USB", External: true}, + {Mount: "/Volumes/Backup", External: true}, + }, + wantInternal: 0, + wantExternal: 2, + }, + { + name: "mixed", + disks: []DiskStatus{ + {Mount: "/", External: false}, + {Mount: "/Volumes/USB", External: true}, + {Mount: "/System", External: false}, + }, + wantInternal: 2, + wantExternal: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + internal, external := splitDisks(tt.disks) + if len(internal) != tt.wantInternal { + t.Errorf("splitDisks() internal count = %d, want %d", len(internal), tt.wantInternal) + } + if len(external) != tt.wantExternal { + t.Errorf("splitDisks() external count = %d, want %d", len(external), tt.wantExternal) + } + }) + } +} + +func TestDiskLabel(t *testing.T) { + tests := []struct { + name string + prefix string + index int + total int + want string + }{ + // Single disk — no numbering. + {"single disk", "INTR", 0, 1, "INTR"}, + {"single external", "EXTR", 0, 1, "EXTR"}, + + // Multiple disks — numbered (1-indexed). + {"first of two", "INTR", 0, 2, "INTR1"}, + {"second of two", "INTR", 1, 2, "INTR2"}, + {"third of three", "EXTR", 2, 3, "EXTR3"}, + + // Edge case: total 0 treated as single. + {"total zero", "DISK", 0, 0, "DISK"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := diskLabel(tt.prefix, tt.index, tt.total) + if got != tt.want { + t.Errorf("diskLabel(%q, %d, %d) = %q, want %q", tt.prefix, tt.index, tt.total, got, tt.want) + } + }) + } +} From 64f79a59d8c6694804c1b94770565b3667c2e1e5 Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 28 Jan 2026 19:46:01 +0800 Subject: [PATCH 06/21] feat: optimize log system and add mo log command - Add get_timestamp() helper and optimize log rotation - Create mo log viewer with search/filter capabilities - Improve test coverage to 18.4% with better assertions - Add security fixes for grep injection prevention --- .gitignore | 5 + bin/log.sh | 224 ++++++++++++++++++++++++++++++++++++++++ cmd/status/view_test.go | 89 ++++++++++++++++ lib/core/commands.sh | 1 + lib/core/log.sh | 108 +++++++++++-------- mole | 3 + 6 files changed, 385 insertions(+), 45 deletions(-) create mode 100755 bin/log.sh diff --git a/.gitignore b/.gitignore index a384879..8efd5c9 100644 --- a/.gitignore +++ b/.gitignore @@ -69,5 +69,10 @@ tests/tmp-*/ tests/*.tmp tests/*.log +# Go test coverage files +*.out +coverage.out +coverage.html + session.json run_tests.ps1 diff --git a/bin/log.sh b/bin/log.sh new file mode 100755 index 0000000..8619853 --- /dev/null +++ b/bin/log.sh @@ -0,0 +1,224 @@ +#!/bin/bash +# Mole - Operations Log Viewer +# Query and analyze operation logs + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_DIR="${SCRIPT_DIR}/../lib" + +# shellcheck source=lib/core/base.sh +source "$LIB_DIR/core/base.sh" +# shellcheck source=lib/core/log.sh +source "$LIB_DIR/core/log.sh" + +show_help() { + cat < Show last N entries (default: 50) + --search Search for specific pattern + --stats Show operation statistics + --today Show only today's operations + --command Filter by command (clean/uninstall/optimize/purge) + --help Show this help message + +EXAMPLES: + mo log # Show last 50 operations + mo log --tail 100 # Show last 100 operations + mo log --search node_modules # Search for node_modules operations + mo log --stats # Show statistics + mo log --today # Show today's operations only + mo log --command clean # Show only clean operations + +LOG LOCATION: + ${OPERATIONS_LOG_FILE} + +EOF +} + +show_tail() { + local count="${1:-50}" + + if [[ ! -f "$OPERATIONS_LOG_FILE" ]]; then + echo -e "${YELLOW}No operation log found.${NC}" + echo "Run some commands (e.g., mo clean) to generate logs." + return 0 + fi + + echo -e "${BLUE}Last ${count} operations:${NC}" + echo "────────────────────────────────────────────────────────────────" + tail -n "$count" "$OPERATIONS_LOG_FILE" +} + +search_log() { + local term="$1" + + if [[ -z "$term" ]]; then + echo -e "${RED}Error: Search term required${NC}" + echo "Usage: mo log --search " + return 1 + fi + + if [[ ! -f "$OPERATIONS_LOG_FILE" ]]; then + echo -e "${YELLOW}No operation log found.${NC}" + return 0 + fi + + echo -e "${BLUE}Searching for: ${term}${NC}" + echo "────────────────────────────────────────────────────────────────" + + local results + results=$(grep -iF -- "$term" "$OPERATIONS_LOG_FILE" 2>/dev/null || true) + + if [[ -z "$results" ]]; then + echo -e "${YELLOW}No matches found.${NC}" + else + echo "$results" + fi +} + +show_stats() { + if [[ ! -f "$OPERATIONS_LOG_FILE" ]]; then + echo -e "${YELLOW}No operation log found.${NC}" + return 0 + fi + + echo -e "${BLUE}Operation Statistics${NC}" + echo "────────────────────────────────────────────────────────────────" + + local total_lines + total_lines=$(grep -c '^\[' "$OPERATIONS_LOG_FILE" 2>/dev/null || echo 0) + echo -e "${GREEN}Total operations:${NC} $total_lines" + echo "" + + echo -e "${GREEN}By command:${NC}" + grep -o '\[clean\]\|\[uninstall\]\|\[optimize\]\|\[purge\]' "$OPERATIONS_LOG_FILE" 2>/dev/null | + sort | uniq -c | sort -rn | sed 's/\[//g; s/\]//g' | + awk '{printf " %-15s %s\n", $2":", $1}' || echo " No command data" + echo "" + + echo -e "${GREEN}By action:${NC}" + grep -o 'REMOVED\|SKIPPED\|FAILED\|REBUILT' "$OPERATIONS_LOG_FILE" 2>/dev/null | + sort | uniq -c | sort -rn | + awk '{printf " %-15s %s\n", $2":", $1}' || echo " No action data" + echo "" + + echo -e "${GREEN}Recent sessions:${NC}" + grep 'session started' "$OPERATIONS_LOG_FILE" 2>/dev/null | tail -n 5 || echo " No session data" +} + +show_today() { + if [[ ! -f "$OPERATIONS_LOG_FILE" ]]; then + echo -e "${YELLOW}No operation log found.${NC}" + return 0 + fi + + local today + today=$(date '+%Y-%m-%d') + + echo -e "${BLUE}Today's operations (${today}):${NC}" + echo "────────────────────────────────────────────────────────────────" + + local results + results=$(grep "^\[$today" "$OPERATIONS_LOG_FILE" 2>/dev/null || true) + + if [[ -z "$results" ]]; then + echo -e "${YELLOW}No operations today.${NC}" + else + echo "$results" + fi +} + +filter_by_command() { + local cmd="$1" + + if [[ -z "$cmd" ]]; then + echo -e "${RED}Error: Command name required${NC}" + echo "Usage: mo log --command " + echo "Available commands: clean, uninstall, optimize, purge" + return 1 + fi + + if [[ ! -f "$OPERATIONS_LOG_FILE" ]]; then + echo -e "${YELLOW}No operation log found.${NC}" + return 0 + fi + + echo -e "${BLUE}Operations for command: ${cmd}${NC}" + echo "────────────────────────────────────────────────────────────────" + + local results + results=$(grep -F -- "[$cmd]" "$OPERATIONS_LOG_FILE" 2>/dev/null || true) + + if [[ -z "$results" ]]; then + echo -e "${YELLOW}No operations found for ${cmd}.${NC}" + else + echo "$results" + fi +} + +main() { + if [[ "${MO_NO_OPLOG:-}" == "1" ]]; then + echo -e "${YELLOW}Operation logging is disabled (MO_NO_OPLOG=1).${NC}" + echo "Enable it by unsetting the MO_NO_OPLOG environment variable." + exit 0 + fi + + if [[ $# -eq 0 ]]; then + show_tail 50 + exit 0 + fi + + while [[ $# -gt 0 ]]; do + case "$1" in + --tail) + shift + show_tail "${1:-50}" + exit 0 + ;; + --search) + shift + if [[ -z "${1:-}" ]]; then + echo -e "${RED}Error: --search requires an argument${NC}" + exit 1 + fi + search_log "$1" + exit 0 + ;; + --stats) + show_stats + exit 0 + ;; + --today) + show_today + exit 0 + ;; + --command) + shift + if [[ -z "${1:-}" ]]; then + echo -e "${RED}Error: --command requires an argument${NC}" + exit 1 + fi + filter_by_command "$1" + exit 0 + ;; + --help | -h) + show_help + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + echo "Use 'mo log --help' for usage information." + exit 1 + ;; + esac + # shellcheck disable=SC2317 + shift + done +} + +main "$@" diff --git a/cmd/status/view_test.go b/cmd/status/view_test.go index f665952..9fc7ca9 100644 --- a/cmd/status/view_test.go +++ b/cmd/status/view_test.go @@ -39,6 +39,95 @@ func TestFormatRate(t *testing.T) { } } +func TestColorizePercent(t *testing.T) { + tests := []struct { + name string + percent float64 + input string + expectDanger bool + expectWarn bool + expectOk bool + }{ + {"low usage", 30.0, "30%", false, false, true}, + {"just below warn", 59.9, "59.9%", false, false, true}, + {"at warn threshold", 60.0, "60%", false, true, false}, + {"mid range", 70.0, "70%", false, true, false}, + {"just below danger", 84.9, "84.9%", false, true, false}, + {"at danger threshold", 85.0, "85%", true, false, false}, + {"high usage", 95.0, "95%", true, false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := colorizePercent(tt.percent, tt.input) + + if got == "" { + t.Errorf("colorizePercent(%v, %q) returned empty string", tt.percent, tt.input) + return + } + + expected := "" + if tt.expectDanger { + expected = dangerStyle.Render(tt.input) + } else if tt.expectWarn { + expected = warnStyle.Render(tt.input) + } else if tt.expectOk { + expected = okStyle.Render(tt.input) + } + + if got != expected { + t.Errorf("colorizePercent(%v, %q) = %q, want %q (danger=%v warn=%v ok=%v)", + tt.percent, tt.input, got, expected, tt.expectDanger, tt.expectWarn, tt.expectOk) + } + }) + } +} + +func TestColorizeBattery(t *testing.T) { + tests := []struct { + name string + percent float64 + input string + expectDanger bool + expectWarn bool + expectOk bool + }{ + {"critical low", 10.0, "10%", true, false, false}, + {"just below low", 19.9, "19.9%", true, false, false}, + {"at low threshold", 20.0, "20%", false, true, false}, + {"mid range", 35.0, "35%", false, true, false}, + {"just below ok", 49.9, "49.9%", false, true, false}, + {"at ok threshold", 50.0, "50%", false, false, true}, + {"healthy", 80.0, "80%", false, false, true}, + {"full", 100.0, "100%", false, false, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := colorizeBattery(tt.percent, tt.input) + + if got == "" { + t.Errorf("colorizeBattery(%v, %q) returned empty string", tt.percent, tt.input) + return + } + + expected := "" + if tt.expectDanger { + expected = dangerStyle.Render(tt.input) + } else if tt.expectWarn { + expected = warnStyle.Render(tt.input) + } else if tt.expectOk { + expected = okStyle.Render(tt.input) + } + + if got != expected { + t.Errorf("colorizeBattery(%v, %q) = %q, want %q (danger=%v warn=%v ok=%v)", + tt.percent, tt.input, got, expected, tt.expectDanger, tt.expectWarn, tt.expectOk) + } + }) + } +} + func TestShorten(t *testing.T) { tests := []struct { name string diff --git a/lib/core/commands.sh b/lib/core/commands.sh index 3d2559e..a17b878 100644 --- a/lib/core/commands.sh +++ b/lib/core/commands.sh @@ -9,6 +9,7 @@ MOLE_COMMANDS=( "status:Monitor system health" "purge:Remove old project artifacts" "installer:Find and remove installer files" + "log:View operation logs" "touchid:Configure Touch ID for sudo" "completion:Setup shell tab completion" "update:Update to latest version" diff --git a/lib/core/log.sh b/lib/core/log.sh index cc933cd..769edb5 100644 --- a/lib/core/log.sh +++ b/lib/core/log.sh @@ -44,17 +44,25 @@ rotate_log_once() { export MOLE_LOG_ROTATED=1 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 - ensure_user_file "$LOG_FILE" + if [[ -f "$LOG_FILE" ]]; then + local size + size=$(get_file_size "$LOG_FILE") + if [[ "$size" -gt "$max_size" ]]; then + mv "$LOG_FILE" "${LOG_FILE}.old" 2>/dev/null || true + ensure_user_file "$LOG_FILE" + fi 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" + if [[ -f "$OPERATIONS_LOG_FILE" ]]; then + local size + size=$(get_file_size "$OPERATIONS_LOG_FILE") + if [[ "$size" -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 fi } @@ -63,51 +71,62 @@ rotate_log_once() { # Logging Functions # ============================================================================ +# Get current timestamp (centralized for consistency) +get_timestamp() { + date '+%Y-%m-%d %H:%M:%S' +} + # Log informational message 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 + local timestamp + timestamp=$(get_timestamp) + 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 } # Log success message 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 + local timestamp + timestamp=$(get_timestamp) + 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 } -# Log warning message +# shellcheck disable=SC2329 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 + local timestamp + timestamp=$(get_timestamp) + 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 } -# Log error message +# shellcheck disable=SC2329 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 + local timestamp + timestamp=$(get_timestamp) + 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 } -# Debug logging (active when MO_DEBUG=1) +# shellcheck disable=SC2329 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 + local timestamp + timestamp=$(get_timestamp) + echo "[$timestamp] DEBUG: $*" >>"$DEBUG_LOG_FILE" 2>/dev/null || true fi } @@ -139,12 +158,12 @@ log_operation() { [[ -z "$path" ]] && return 0 local timestamp - timestamp=$(date '+%Y-%m-%d %H:%M:%S') + timestamp=$(get_timestamp) 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 @@ -154,16 +173,15 @@ log_operation_session_start() { local command="${1:-mole}" local timestamp - timestamp=$(date '+%Y-%m-%d %H:%M:%S') + timestamp=$(get_timestamp) { 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 -# Usage: log_operation_session_end +# shellcheck disable=SC2329 log_operation_session_end() { oplog_enabled || return 0 @@ -171,18 +189,18 @@ log_operation_session_end() { local items="${2:-0}" local size="${3:-0}" local timestamp - timestamp=$(date '+%Y-%m-%d %H:%M:%S') + timestamp=$(get_timestamp) 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 +218,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 +232,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 +252,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 +264,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 +285,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 +298,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 +322,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 +330,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/mole b/mole index 8761cb1..b9c291a 100755 --- a/mole +++ b/mole @@ -777,6 +777,9 @@ main() { "installer") exec "$SCRIPT_DIR/bin/installer.sh" "${args[@]:1}" ;; + "log") + exec "$SCRIPT_DIR/bin/log.sh" "${args[@]:1}" + ;; "touchid") exec "$SCRIPT_DIR/bin/touchid.sh" "${args[@]:1}" ;; From c12f76c9f3066e0299b89763a62ee62448cfee04 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Wed, 28 Jan 2026 11:46:59 +0000 Subject: [PATCH 07/21] chore: auto format code --- bin/log.sh | 94 ++++++++++++++++++++++++------------------------- lib/core/log.sh | 58 +++++++++++++++--------------- 2 files changed, 76 insertions(+), 76 deletions(-) diff --git a/bin/log.sh b/bin/log.sh index 8619853..ba6e803 100755 --- a/bin/log.sh +++ b/bin/log.sh @@ -13,7 +13,7 @@ source "$LIB_DIR/core/base.sh" source "$LIB_DIR/core/log.sh" show_help() { - cat </dev/null || true) + results=$(grep -iF -- "$term" "$OPERATIONS_LOG_FILE" 2> /dev/null || true) if [[ -z "$results" ]]; then echo -e "${YELLOW}No matches found.${NC}" @@ -91,24 +91,24 @@ show_stats() { echo "────────────────────────────────────────────────────────────────" local total_lines - total_lines=$(grep -c '^\[' "$OPERATIONS_LOG_FILE" 2>/dev/null || echo 0) + total_lines=$(grep -c '^\[' "$OPERATIONS_LOG_FILE" 2> /dev/null || echo 0) echo -e "${GREEN}Total operations:${NC} $total_lines" echo "" echo -e "${GREEN}By command:${NC}" - grep -o '\[clean\]\|\[uninstall\]\|\[optimize\]\|\[purge\]' "$OPERATIONS_LOG_FILE" 2>/dev/null | + grep -o '\[clean\]\|\[uninstall\]\|\[optimize\]\|\[purge\]' "$OPERATIONS_LOG_FILE" 2> /dev/null | sort | uniq -c | sort -rn | sed 's/\[//g; s/\]//g' | awk '{printf " %-15s %s\n", $2":", $1}' || echo " No command data" echo "" echo -e "${GREEN}By action:${NC}" - grep -o 'REMOVED\|SKIPPED\|FAILED\|REBUILT' "$OPERATIONS_LOG_FILE" 2>/dev/null | + grep -o 'REMOVED\|SKIPPED\|FAILED\|REBUILT' "$OPERATIONS_LOG_FILE" 2> /dev/null | sort | uniq -c | sort -rn | awk '{printf " %-15s %s\n", $2":", $1}' || echo " No action data" echo "" echo -e "${GREEN}Recent sessions:${NC}" - grep 'session started' "$OPERATIONS_LOG_FILE" 2>/dev/null | tail -n 5 || echo " No session data" + grep 'session started' "$OPERATIONS_LOG_FILE" 2> /dev/null | tail -n 5 || echo " No session data" } show_today() { @@ -124,7 +124,7 @@ show_today() { echo "────────────────────────────────────────────────────────────────" local results - results=$(grep "^\[$today" "$OPERATIONS_LOG_FILE" 2>/dev/null || true) + results=$(grep "^\[$today" "$OPERATIONS_LOG_FILE" 2> /dev/null || true) if [[ -z "$results" ]]; then echo -e "${YELLOW}No operations today.${NC}" @@ -152,7 +152,7 @@ filter_by_command() { echo "────────────────────────────────────────────────────────────────" local results - results=$(grep -F -- "[$cmd]" "$OPERATIONS_LOG_FILE" 2>/dev/null || true) + results=$(grep -F -- "[$cmd]" "$OPERATIONS_LOG_FILE" 2> /dev/null || true) if [[ -z "$results" ]]; then echo -e "${YELLOW}No operations found for ${cmd}.${NC}" @@ -175,46 +175,46 @@ main() { while [[ $# -gt 0 ]]; do case "$1" in - --tail) - shift - show_tail "${1:-50}" - exit 0 - ;; - --search) - shift - if [[ -z "${1:-}" ]]; then - echo -e "${RED}Error: --search requires an argument${NC}" + --tail) + shift + show_tail "${1:-50}" + exit 0 + ;; + --search) + shift + if [[ -z "${1:-}" ]]; then + echo -e "${RED}Error: --search requires an argument${NC}" + exit 1 + fi + search_log "$1" + exit 0 + ;; + --stats) + show_stats + exit 0 + ;; + --today) + show_today + exit 0 + ;; + --command) + shift + if [[ -z "${1:-}" ]]; then + echo -e "${RED}Error: --command requires an argument${NC}" + exit 1 + fi + filter_by_command "$1" + exit 0 + ;; + --help | -h) + show_help + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + echo "Use 'mo log --help' for usage information." exit 1 - fi - search_log "$1" - exit 0 - ;; - --stats) - show_stats - exit 0 - ;; - --today) - show_today - exit 0 - ;; - --command) - shift - if [[ -z "${1:-}" ]]; then - echo -e "${RED}Error: --command requires an argument${NC}" - exit 1 - fi - filter_by_command "$1" - exit 0 - ;; - --help | -h) - show_help - exit 0 - ;; - *) - echo -e "${RED}Unknown option: $1${NC}" - echo "Use 'mo log --help' for usage information." - exit 1 - ;; + ;; esac # shellcheck disable=SC2317 shift diff --git a/lib/core/log.sh b/lib/core/log.sh index 769edb5..797b92f 100644 --- a/lib/core/log.sh +++ b/lib/core/log.sh @@ -48,7 +48,7 @@ rotate_log_once() { local size size=$(get_file_size "$LOG_FILE") if [[ "$size" -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 fi @@ -60,7 +60,7 @@ rotate_log_once() { local size size=$(get_file_size "$OPERATIONS_LOG_FILE") if [[ "$size" -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 @@ -81,9 +81,9 @@ log_info() { echo -e "${BLUE}$1${NC}" local timestamp timestamp=$(get_timestamp) - 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 } @@ -92,9 +92,9 @@ log_success() { echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1" local timestamp timestamp=$(get_timestamp) - 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 } @@ -103,9 +103,9 @@ log_warning() { echo -e "${YELLOW}$1${NC}" local timestamp timestamp=$(get_timestamp) - 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 } @@ -114,9 +114,9 @@ log_error() { echo -e "${YELLOW}${ICON_ERROR}${NC} $1" >&2 local timestamp timestamp=$(get_timestamp) - 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 } @@ -126,7 +126,7 @@ debug_log() { echo -e "${GRAY}[DEBUG]${NC} $*" >&2 local timestamp timestamp=$(get_timestamp) - echo "[$timestamp] DEBUG: $*" >>"$DEBUG_LOG_FILE" 2>/dev/null || true + echo "[$timestamp] DEBUG: $*" >> "$DEBUG_LOG_FILE" 2> /dev/null || true fi } @@ -163,7 +163,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 @@ -178,7 +178,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 } # shellcheck disable=SC2329 @@ -193,14 +193,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 @@ -218,7 +218,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 } @@ -232,7 +232,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 } @@ -252,7 +252,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 } @@ -264,16 +264,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 } @@ -285,7 +285,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 @@ -298,19 +298,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 @@ -322,7 +322,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 @@ -330,12 +330,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 88da841ce9d31324a5c73adc8c627422e06202d8 Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 28 Jan 2026 19:58:59 +0800 Subject: [PATCH 08/21] refactor: remove mo log command, keep log recording only --- bin/log.sh | 224 ------------------------------------------- lib/core/commands.sh | 1 - mole | 3 - 3 files changed, 228 deletions(-) delete mode 100755 bin/log.sh diff --git a/bin/log.sh b/bin/log.sh deleted file mode 100755 index ba6e803..0000000 --- a/bin/log.sh +++ /dev/null @@ -1,224 +0,0 @@ -#!/bin/bash -# Mole - Operations Log Viewer -# Query and analyze operation logs - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LIB_DIR="${SCRIPT_DIR}/../lib" - -# shellcheck source=lib/core/base.sh -source "$LIB_DIR/core/base.sh" -# shellcheck source=lib/core/log.sh -source "$LIB_DIR/core/log.sh" - -show_help() { - cat << EOF -Usage: mo log [OPTIONS] - -View and analyze Mole operation logs. - -OPTIONS: - --tail Show last N entries (default: 50) - --search Search for specific pattern - --stats Show operation statistics - --today Show only today's operations - --command Filter by command (clean/uninstall/optimize/purge) - --help Show this help message - -EXAMPLES: - mo log # Show last 50 operations - mo log --tail 100 # Show last 100 operations - mo log --search node_modules # Search for node_modules operations - mo log --stats # Show statistics - mo log --today # Show today's operations only - mo log --command clean # Show only clean operations - -LOG LOCATION: - ${OPERATIONS_LOG_FILE} - -EOF -} - -show_tail() { - local count="${1:-50}" - - if [[ ! -f "$OPERATIONS_LOG_FILE" ]]; then - echo -e "${YELLOW}No operation log found.${NC}" - echo "Run some commands (e.g., mo clean) to generate logs." - return 0 - fi - - echo -e "${BLUE}Last ${count} operations:${NC}" - echo "────────────────────────────────────────────────────────────────" - tail -n "$count" "$OPERATIONS_LOG_FILE" -} - -search_log() { - local term="$1" - - if [[ -z "$term" ]]; then - echo -e "${RED}Error: Search term required${NC}" - echo "Usage: mo log --search " - return 1 - fi - - if [[ ! -f "$OPERATIONS_LOG_FILE" ]]; then - echo -e "${YELLOW}No operation log found.${NC}" - return 0 - fi - - echo -e "${BLUE}Searching for: ${term}${NC}" - echo "────────────────────────────────────────────────────────────────" - - local results - results=$(grep -iF -- "$term" "$OPERATIONS_LOG_FILE" 2> /dev/null || true) - - if [[ -z "$results" ]]; then - echo -e "${YELLOW}No matches found.${NC}" - else - echo "$results" - fi -} - -show_stats() { - if [[ ! -f "$OPERATIONS_LOG_FILE" ]]; then - echo -e "${YELLOW}No operation log found.${NC}" - return 0 - fi - - echo -e "${BLUE}Operation Statistics${NC}" - echo "────────────────────────────────────────────────────────────────" - - local total_lines - total_lines=$(grep -c '^\[' "$OPERATIONS_LOG_FILE" 2> /dev/null || echo 0) - echo -e "${GREEN}Total operations:${NC} $total_lines" - echo "" - - echo -e "${GREEN}By command:${NC}" - grep -o '\[clean\]\|\[uninstall\]\|\[optimize\]\|\[purge\]' "$OPERATIONS_LOG_FILE" 2> /dev/null | - sort | uniq -c | sort -rn | sed 's/\[//g; s/\]//g' | - awk '{printf " %-15s %s\n", $2":", $1}' || echo " No command data" - echo "" - - echo -e "${GREEN}By action:${NC}" - grep -o 'REMOVED\|SKIPPED\|FAILED\|REBUILT' "$OPERATIONS_LOG_FILE" 2> /dev/null | - sort | uniq -c | sort -rn | - awk '{printf " %-15s %s\n", $2":", $1}' || echo " No action data" - echo "" - - echo -e "${GREEN}Recent sessions:${NC}" - grep 'session started' "$OPERATIONS_LOG_FILE" 2> /dev/null | tail -n 5 || echo " No session data" -} - -show_today() { - if [[ ! -f "$OPERATIONS_LOG_FILE" ]]; then - echo -e "${YELLOW}No operation log found.${NC}" - return 0 - fi - - local today - today=$(date '+%Y-%m-%d') - - echo -e "${BLUE}Today's operations (${today}):${NC}" - echo "────────────────────────────────────────────────────────────────" - - local results - results=$(grep "^\[$today" "$OPERATIONS_LOG_FILE" 2> /dev/null || true) - - if [[ -z "$results" ]]; then - echo -e "${YELLOW}No operations today.${NC}" - else - echo "$results" - fi -} - -filter_by_command() { - local cmd="$1" - - if [[ -z "$cmd" ]]; then - echo -e "${RED}Error: Command name required${NC}" - echo "Usage: mo log --command " - echo "Available commands: clean, uninstall, optimize, purge" - return 1 - fi - - if [[ ! -f "$OPERATIONS_LOG_FILE" ]]; then - echo -e "${YELLOW}No operation log found.${NC}" - return 0 - fi - - echo -e "${BLUE}Operations for command: ${cmd}${NC}" - echo "────────────────────────────────────────────────────────────────" - - local results - results=$(grep -F -- "[$cmd]" "$OPERATIONS_LOG_FILE" 2> /dev/null || true) - - if [[ -z "$results" ]]; then - echo -e "${YELLOW}No operations found for ${cmd}.${NC}" - else - echo "$results" - fi -} - -main() { - if [[ "${MO_NO_OPLOG:-}" == "1" ]]; then - echo -e "${YELLOW}Operation logging is disabled (MO_NO_OPLOG=1).${NC}" - echo "Enable it by unsetting the MO_NO_OPLOG environment variable." - exit 0 - fi - - if [[ $# -eq 0 ]]; then - show_tail 50 - exit 0 - fi - - while [[ $# -gt 0 ]]; do - case "$1" in - --tail) - shift - show_tail "${1:-50}" - exit 0 - ;; - --search) - shift - if [[ -z "${1:-}" ]]; then - echo -e "${RED}Error: --search requires an argument${NC}" - exit 1 - fi - search_log "$1" - exit 0 - ;; - --stats) - show_stats - exit 0 - ;; - --today) - show_today - exit 0 - ;; - --command) - shift - if [[ -z "${1:-}" ]]; then - echo -e "${RED}Error: --command requires an argument${NC}" - exit 1 - fi - filter_by_command "$1" - exit 0 - ;; - --help | -h) - show_help - exit 0 - ;; - *) - echo -e "${RED}Unknown option: $1${NC}" - echo "Use 'mo log --help' for usage information." - exit 1 - ;; - esac - # shellcheck disable=SC2317 - shift - done -} - -main "$@" diff --git a/lib/core/commands.sh b/lib/core/commands.sh index a17b878..3d2559e 100644 --- a/lib/core/commands.sh +++ b/lib/core/commands.sh @@ -9,7 +9,6 @@ MOLE_COMMANDS=( "status:Monitor system health" "purge:Remove old project artifacts" "installer:Find and remove installer files" - "log:View operation logs" "touchid:Configure Touch ID for sudo" "completion:Setup shell tab completion" "update:Update to latest version" diff --git a/mole b/mole index b9c291a..8761cb1 100755 --- a/mole +++ b/mole @@ -777,9 +777,6 @@ main() { "installer") exec "$SCRIPT_DIR/bin/installer.sh" "${args[@]:1}" ;; - "log") - exec "$SCRIPT_DIR/bin/log.sh" "${args[@]:1}" - ;; "touchid") exec "$SCRIPT_DIR/bin/touchid.sh" "${args[@]:1}" ;; From 0a46bf20771360df14e9b007f2dc7cb5fda7d49f Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 28 Jan 2026 20:15:26 +0800 Subject: [PATCH 09/21] =?UTF-8?q?fix:=20improve=20status=20icons=20and=20f?= =?UTF-8?q?ix=20spinner=20cleanup=20-=20Show=20=E2=9C=93=20for=20empty=20t?= =?UTF-8?q?rash=20and=20discovered=20versions=20(normal=20states)=20-=20Fi?= =?UTF-8?q?x=20inline=20spinner=20not=20stopping,=20causing=20residual=20d?= =?UTF-8?q?isplay=20artifacts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/clean.sh | 2 +- lib/clean/dev.sh | 2 +- lib/clean/user.sh | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bin/clean.sh b/bin/clean.sh index 0c789b0..dede154 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -601,7 +601,7 @@ safe_clean() { fi if [[ "$show_spinner" == "true" || "$cleaning_spinner_started" == "true" ]]; then - stop_section_spinner + stop_inline_spinner fi local permission_end=${MOLE_PERMISSION_DENIED_COUNT:-0} diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index e3d7c44..6f441f1 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 " ${GRAY}${ICON_WARNING}${NC} ${tool_name}: ${count} found${hint}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${tool_name}: ${count} found${hint}" fi } diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 50d8f31..a67658f 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -14,7 +14,7 @@ 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 " ${GREEN}${ICON_SUCCESS}${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" @@ -23,7 +23,7 @@ clean_user_essentials() { safe_clean ~/.Trash/* "Trash" fi else - echo -e " ${GRAY}${ICON_EMPTY}${NC} Trash · already empty" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty" fi fi } From 3a7f15aa3253b274377cb03441e673da28a00f4e Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 29 Jan 2026 11:15:14 +0800 Subject: [PATCH 10/21] fix(check): use numeric df output for disk space --- lib/check/all.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/check/all.sh b/lib/check/all.sh index ee100cd..1126826 100644 --- a/lib/check/all.sh +++ b/lib/check/all.sh @@ -422,8 +422,11 @@ get_macos_update_labels() { # ============================================================================ check_disk_space() { - local free_gb=$(command df -H / | awk 'NR==2 {print $4}' | sed 's/G//') - local free_num=$(echo "$free_gb" | tr -d 'G' | cut -d'.' -f1) + # Use df -k to get KB values (always numeric), then calculate GB via math + # This avoids unit suffix parsing issues (df -H can return MB or GB) + local free_kb=$(command df -k / | awk 'NR==2 {print $4}') + local free_gb=$(awk "BEGIN {printf \"%.1f\", $free_kb / 1048576}") + local free_num=$(awk "BEGIN {printf \"%d\", $free_kb / 1048576}") export DISK_FREE_GB=$free_num From d2e6917cceed1647279581e487ff01596c586ee4 Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 29 Jan 2026 11:37:15 +0800 Subject: [PATCH 11/21] update supporters --- README.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6264759..e73df6b 100644 --- a/README.md +++ b/README.md @@ -274,13 +274,9 @@ Real feedback from users who shared Mole on X. - 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! 🥤 +- Like Mole? Buy Tw93 a Coke to support the project! 🥤 Supporters below. -
-Friends who bought me Coke -
- -
+ ## License From 47ce6b0c50c9012ba9831de6c9d52c10a10b39ee Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 30 Jan 2026 11:06:54 +0800 Subject: [PATCH 12/21] Fix the issue with automated testing --- tests/dev_extended.bats | 8 ++++---- tests/uninstall_naming_variants.bats | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/dev_extended.bats b/tests/dev_extended.bats index f7a66ec..d6c19fd 100644 --- a/tests/dev_extended.bats +++ b/tests/dev_extended.bats @@ -123,28 +123,28 @@ 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="" && YELLOW="" && ICON_WARNING="●" && 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_SUCCESS="✓" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" [ "$status" -eq 0 ] [[ "$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="" && YELLOW="" && ICON_WARNING="●" && 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_SUCCESS="✓" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" [ "$status" -eq 0 ] [[ "$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="" && YELLOW="" && ICON_WARNING="●" && 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_SUCCESS="✓" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" [ "$status" -eq 0 ] [[ "$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="" && YELLOW="" && ICON_WARNING="●" && 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_SUCCESS="✓" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" [ "$status" -eq 0 ] [[ "$output" != *"Rust toolchains"* ]] diff --git a/tests/uninstall_naming_variants.bats b/tests/uninstall_naming_variants.bats index 49b0ad2..efd687f 100644 --- a/tests/uninstall_naming_variants.bats +++ b/tests/uninstall_naming_variants.bats @@ -36,7 +36,7 @@ setup() { result=$(find_app_files "com.maestro.studio" "Maestro Studio") - [[ "$result" =~ ".config/maestro-studio" ]] + [[ "$result" =~ .config/maestro-studio ]] } @test "find_app_files detects no-space variant (MaestroStudio)" { @@ -56,7 +56,7 @@ setup() { result=$(find_app_files "dev.zed.Zed-Nightly" "Zed Nightly") - [[ "$result" =~ ".config/zed" ]] + [[ "$result" =~ .config/zed ]] [[ "$result" =~ "Library/Application Support/Zed" ]] } @@ -73,10 +73,10 @@ setup() { result=$(find_app_files "com.maestro.studio" "Maestro Studio") - [[ "$result" =~ ".config/maestro-studio" ]] + [[ "$result" =~ .config/maestro-studio ]] [[ "$result" =~ "Library/Application Support/MaestroStudio" ]] [[ "$result" =~ "Library/Application Support/Maestro-Studio" ]] - [[ "$result" =~ ".local/share/maestrostudio" ]] + [[ "$result" =~ .local/share/maestrostudio ]] } @test "find_app_files handles multi-word version suffix (Firefox Developer Edition)" { @@ -85,7 +85,7 @@ setup() { result=$(find_app_files "org.mozilla.firefoxdeveloperedition" "Firefox Developer Edition") - [[ "$result" =~ ".local/share/firefox" ]] + [[ "$result" =~ .local/share/firefox ]] } @test "find_app_files does not match empty app name" { From d06cf6a69eab39ef5d3eadf02e4ef38426e4023d Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 30 Jan 2026 11:13:29 +0800 Subject: [PATCH 13/21] fix: correct blue color definition in base.sh --- 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 3c9e4c0..0f0170c 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -15,7 +15,7 @@ readonly MOLE_BASE_LOADED=1 # ============================================================================ readonly ESC=$'\033' readonly GREEN="${ESC}[0;32m" -readonly BLUE="${ESC}[0;34m" +readonly BLUE="${ESC}[1;34m" readonly CYAN="${ESC}[0;36m" readonly YELLOW="${ESC}[0;33m" readonly PURPLE="${ESC}[0;35m" From e96868824b6341e10cc497f78f8dd9423bb68f21 Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 30 Jan 2026 11:19:10 +0800 Subject: [PATCH 14/21] feat: clean Apple Podcasts zombie StreamedMedia files (#387) Add cleanup for sandboxed container tmp directory where zombie sparse files can accumulate (0-byte files with ~93MB pre-allocated APFS blocks). --- lib/clean/app_caches.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/clean/app_caches.sh b/lib/clean/app_caches.sh index 2402f09..c4ae7a7 100644 --- a/lib/clean/app_caches.sh +++ b/lib/clean/app_caches.sh @@ -114,6 +114,11 @@ clean_media_players() { fi safe_clean ~/Library/Caches/com.apple.Music "Apple Music cache" safe_clean ~/Library/Caches/com.apple.podcasts "Apple Podcasts cache" + # Apple Podcasts sandbox container: zombie sparse files and stale artwork cache (#387) + safe_clean ~/Library/Containers/com.apple.podcasts/Data/tmp/StreamedMedia "Podcasts streamed media" + safe_clean ~/Library/Containers/com.apple.podcasts/Data/tmp/*.heic "Podcasts artwork cache" + safe_clean ~/Library/Containers/com.apple.podcasts/Data/tmp/*.img "Podcasts image cache" + safe_clean ~/Library/Containers/com.apple.podcasts/Data/tmp/*CFNetworkDownload*.tmp "Podcasts download temp" safe_clean ~/Library/Caches/com.apple.TV/* "Apple TV cache" safe_clean ~/Library/Caches/tv.plex.player.desktop "Plex cache" safe_clean ~/Library/Caches/com.netease.163music "NetEase Music cache" From 2865a788e898fffe58e49f401ce65c0b405ded3b Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 30 Jan 2026 13:57:43 +0800 Subject: [PATCH 15/21] test: add checks for uninstallable Apple apps in should_protect_from_uninstall function --- lib/core/app_protection.sh | 767 +++++++++++++++++++++---------------- tests/core_common.bats | 26 ++ 2 files changed, 457 insertions(+), 336 deletions(-) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 9f0ea6d..7fae723 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -20,20 +20,92 @@ fi # Application Management # Critical system components protected from uninstallation +# Note: We explicitly list system components instead of using "com.apple.*" wildcard +# to allow uninstallation of user-installed Apple apps (Xcode, Final Cut Pro, etc.) readonly SYSTEM_CRITICAL_BUNDLES=( - "com.apple.*" # System essentials + # Core system applications (in /System/Applications/) + "com.apple.finder" + "com.apple.dock" + "com.apple.Safari" + "com.apple.mail" + "com.apple.systempreferences" + "com.apple.SystemSettings" + "com.apple.Settings*" + "com.apple.controlcenter*" + "com.apple.Spotlight" + "com.apple.notificationcenterui" + "com.apple.loginwindow" + "com.apple.Preview" + "com.apple.TextEdit" + "com.apple.Notes" + "com.apple.reminders" + "com.apple.iCal" + "com.apple.AddressBook" + "com.apple.Photos" + "com.apple.AppStore" + "com.apple.calculator" + "com.apple.Dictionary" + "com.apple.ScreenSharing" + "com.apple.ActivityMonitor" + "com.apple.Console" + "com.apple.DiskUtility" + "com.apple.KeychainAccess" + "com.apple.DigitalColorMeter" + "com.apple.grapher" + "com.apple.Terminal" + "com.apple.ScriptEditor2" + "com.apple.VoiceOverUtility" + "com.apple.BluetoothFileExchange" + "com.apple.print.PrinterProxy" + "com.apple.systempreferences*" + "com.apple.SystemProfiler" + "com.apple.FontBook" + "com.apple.ColorSyncUtility" + "com.apple.audio.AudioMIDISetup" + "com.apple.DirectoryUtility" + "com.apple.NetworkUtility" + "com.apple.exposelauncher" + "com.apple.MigrateAssistant" + "com.apple.RAIDUtility" + "com.apple.BootCampAssistant" + + # System services and daemons + "com.apple.SecurityAgent" + "com.apple.CoreServices*" + "com.apple.SystemUIServer" + "com.apple.backgroundtaskmanagement*" + "com.apple.loginitems*" + "com.apple.sharedfilelist*" + "com.apple.sfl*" + "com.apple.coreservices*" + "com.apple.metadata*" + "com.apple.MobileSoftwareUpdate*" + "com.apple.SoftwareUpdate*" + "com.apple.installer*" + "com.apple.frameworks*" + "com.apple.security*" + "com.apple.keychain*" + "com.apple.trustd*" + "com.apple.securityd*" + "com.apple.cloudd*" + "com.apple.iCloud*" + "com.apple.WiFi*" + "com.apple.airport*" + "com.apple.Bluetooth*" + + # Input methods (system built-in) + "com.apple.inputmethod.*" + "com.apple.inputsource*" + "com.apple.TextInput*" + "com.apple.CharacterPicker*" + "com.apple.PressAndHold*" + + # Legacy pattern-based entries (non com.apple.*) "loginwindow" "dock" "systempreferences" "finder" "safari" - "com.apple.Settings*" - "com.apple.SystemSettings*" - "com.apple.controlcenter*" - "com.apple.backgroundtaskmanagement*" - "com.apple.loginitems*" - "com.apple.sharedfilelist*" - "com.apple.sfl*" "backgroundtaskmanagementagent" "keychain*" "security*" @@ -55,11 +127,22 @@ readonly SYSTEM_CRITICAL_BUNDLES=( "KeyLayout*" "GlobalPreferences" ".GlobalPreferences" - "com.apple.inputmethod.*" "org.pqrs.Karabiner*" - "com.apple.inputsource*" - "com.apple.TextInputMenuAgent" - "com.apple.TextInputSwitcher" +) + +# Apple apps that CAN be uninstalled (from App Store or developer.apple.com) +readonly APPLE_UNINSTALLABLE_APPS=( + "com.apple.dt.*" # Xcode, Instruments, FileMerge + "com.apple.FinalCut*" # Final Cut Pro + "com.apple.Motion" + "com.apple.Compressor" + "com.apple.logic*" # Logic Pro + "com.apple.garageband*" # GarageBand + "com.apple.iMovie" + "com.apple.iWork.*" # Pages, Numbers, Keynote + "com.apple.MainStage*" + "com.apple.server.*" # macOS Server + "com.apple.Playgrounds" # Swift Playgrounds ) # Applications with sensitive data; protected during cleanup but removable @@ -74,360 +157,355 @@ readonly DATA_PROTECTED_BUNDLES=( "*.InputMethod" "*IME" - # System Utilities & Cleanup Tools - "com.nektony.*" # App Cleaner & Uninstaller - "com.macpaw.*" # CleanMyMac, CleanMaster - "com.freemacsoft.AppCleaner" # AppCleaner - "com.omnigroup.omnidisksweeper" # OmniDiskSweeper - "com.daisydiskapp.*" # DaisyDisk - "com.tunabellysoftware.*" # Disk Utility apps - "com.grandperspectiv.*" # GrandPerspective - "com.binaryfruit.*" # FusionCast + # System Utilities & Cleanup + "com.nektony.*" + "com.macpaw.*" + "com.freemacsoft.AppCleaner" + "com.omnigroup.omnidisksweeper" + "com.daisydiskapp.*" + "com.tunabellysoftware.*" + "com.grandperspectiv.*" + "com.binaryfruit.*" - # Password Managers & Security - "com.1password.*" # 1Password - "com.agilebits.*" # 1Password legacy - "com.lastpass.*" # LastPass - "com.dashlane.*" # Dashlane - "com.bitwarden.*" # Bitwarden - "com.keepassx.*" # KeePassXC (Legacy) - "org.keepassx.*" # KeePassX - "org.keepassxc.*" # KeePassXC - "com.authy.*" # Authy - "com.yubico.*" # YubiKey Manager + # Password Managers + "com.1password.*" + "com.agilebits.*" + "com.lastpass.*" + "com.dashlane.*" + "com.bitwarden.*" + "com.keepassx.*" + "org.keepassx.*" + "org.keepassxc.*" + "com.authy.*" + "com.yubico.*" - # Development Tools - IDEs & Editors - "com.jetbrains.*" # JetBrains IDEs (IntelliJ, DataGrip, etc.) - "JetBrains*" # JetBrains Application Support folders - "com.microsoft.VSCode" # Visual Studio Code - "com.visualstudio.code.*" # VS Code variants - "com.sublimetext.*" # Sublime Text - "com.sublimehq.*" # Sublime Merge - "com.microsoft.VSCodeInsiders" # VS Code Insiders - "com.apple.dt.Xcode" # Xcode (keep settings) - "com.coteditor.CotEditor" # CotEditor - "com.macromates.TextMate" # TextMate - "com.panic.Nova" # Nova - "abnerworks.Typora" # Typora (Markdown editor) - "com.uranusjr.macdown" # MacDown + # IDEs & Editors + "com.jetbrains.*" + "JetBrains*" + "com.microsoft.VSCode" + "com.visualstudio.code.*" + "com.sublimetext.*" + "com.sublimehq.*" + "com.microsoft.VSCodeInsiders" + "com.apple.dt.Xcode" + "com.coteditor.CotEditor" + "com.macromates.TextMate" + "com.panic.Nova" + "abnerworks.Typora" + "com.uranusjr.macdown" # AI & LLM Tools - "com.todesktop.*" # Cursor (often uses generic todesktop ID) - "Cursor" # Cursor App Support - "com.anthropic.claude*" # Claude - "Claude" # Claude App Support - "com.openai.chat*" # ChatGPT - "ChatGPT" # ChatGPT App Support - "com.ollama.ollama" # Ollama - "Ollama" # Ollama App Support - "com.lmstudio.lmstudio" # LM Studio - "LM Studio" # LM Studio App Support - "co.supertool.chatbox" # Chatbox - "page.jan.jan" # Jan - "com.huggingface.huggingchat" # HuggingChat - "Gemini" # Gemini - "com.perplexity.Perplexity" # Perplexity - "com.drawthings.DrawThings" # Draw Things - "com.divamgupta.diffusionbee" # DiffusionBee - "com.exafunction.windsurf" # Windsurf - "com.quora.poe.electron" # Poe - "chat.openai.com.*" # OpenAI web wrappers + "com.todesktop.*" + "Cursor" + "com.anthropic.claude*" + "Claude" + "com.openai.chat*" + "ChatGPT" + "com.ollama.ollama" + "Ollama" + "com.lmstudio.lmstudio" + "LM Studio" + "co.supertool.chatbox" + "page.jan.jan" + "com.huggingface.huggingchat" + "Gemini" + "com.perplexity.Perplexity" + "com.drawthings.DrawThings" + "com.divamgupta.diffusionbee" + "com.exafunction.windsurf" + "com.quora.poe.electron" + "chat.openai.com.*" - # Development Tools - Database Clients - "com.sequelpro.*" # Sequel Pro - "com.sequel-ace.*" # Sequel Ace - "com.tinyapp.*" # TablePlus - "com.dbeaver.*" # DBeaver - "com.navicat.*" # Navicat - "com.mongodb.compass" # MongoDB Compass - "com.redis.RedisInsight" # Redis Insight - "com.pgadmin.pgadmin4" # pgAdmin - "com.eggerapps.Sequel-Pro" # Sequel Pro legacy - "com.valentina-db.Valentina-Studio" # Valentina Studio - "com.dbvis.DbVisualizer" # DbVisualizer + # Database Clients + "com.sequelpro.*" + "com.sequel-ace.*" + "com.tinyapp.*" + "com.dbeaver.*" + "com.navicat.*" + "com.mongodb.compass" + "com.redis.RedisInsight" + "com.pgadmin.pgadmin4" + "com.eggerapps.Sequel-Pro" + "com.valentina-db.Valentina-Studio" + "com.dbvis.DbVisualizer" - # Development Tools - API & Network - "com.postmanlabs.mac" # Postman - "com.konghq.insomnia" # Insomnia - "com.CharlesProxy.*" # Charles Proxy - "com.proxyman.*" # Proxyman - "com.getpaw.*" # Paw - "com.luckymarmot.Paw" # Paw legacy - "com.charlesproxy.charles" # Charles - "com.telerik.Fiddler" # Fiddler - "com.usebruno.app" # Bruno (API client) + # API & Network Tools + "com.postmanlabs.mac" + "com.konghq.insomnia" + "com.CharlesProxy.*" + "com.proxyman.*" + "com.getpaw.*" + "com.luckymarmot.Paw" + "com.charlesproxy.charles" + "com.telerik.Fiddler" + "com.usebruno.app" - # Network Proxy & VPN Tools (pattern-based protection) - # Clash variants - "*clash*" # All Clash variants (ClashX, ClashX Pro, Clash Verge, etc) - "*Clash*" # Capitalized variants - "com.nssurge.surge-mac" # Surge - "*surge*" # Surge variants - "*Surge*" # Surge variants - "mihomo*" # Mihomo Party and variants - "*openvpn*" # OpenVPN Connect and variants - "*OpenVPN*" # OpenVPN capitalized variants - "net.openvpn.*" # OpenVPN bundle IDs + # Network Proxy & VPN Tools + "*clash*" + "*Clash*" + "com.nssurge.surge-mac" + "*surge*" + "*Surge*" + "mihomo*" + "*openvpn*" + "*OpenVPN*" + "net.openvpn.*" - # Proxy Clients (Shadowsocks, V2Ray, etc) - "*ShadowsocksX-NG*" # ShadowsocksX-NG - "com.qiuyuzhou.*" # ShadowsocksX-NG bundle - "*v2ray*" # V2Ray variants - "*V2Ray*" # V2Ray variants - "*v2box*" # V2Box - "*V2Box*" # V2Box - "*nekoray*" # Nekoray - "*sing-box*" # Sing-box - "*OneBox*" # OneBox - "*hiddify*" # Hiddify - "*Hiddify*" # Hiddify - "*loon*" # Loon - "*Loon*" # Loon - "*quantumult*" # Quantumult X + # Proxy Clients + "*ShadowsocksX-NG*" + "com.qiuyuzhou.*" + "*v2ray*" + "*V2Ray*" + "*v2box*" + "*V2Box*" + "*nekoray*" + "*sing-box*" + "*OneBox*" + "*hiddify*" + "*Hiddify*" + "*loon*" + "*Loon*" + "*quantumult*" # Mesh & Corporate VPNs - "*tailscale*" # Tailscale - "io.tailscale.*" # Tailscale bundle - "*zerotier*" # ZeroTier - "com.zerotier.*" # ZeroTier bundle - "*1dot1dot1dot1*" # Cloudflare WARP - "*cloudflare*warp*" # Cloudflare WARP + "*tailscale*" + "io.tailscale.*" + "*zerotier*" + "com.zerotier.*" + "*1dot1dot1dot1*" # Cloudflare WARP + "*cloudflare*warp*" # Commercial VPNs - "*nordvpn*" # NordVPN - "*expressvpn*" # ExpressVPN - "*protonvpn*" # ProtonVPN - "*surfshark*" # Surfshark - "*windscribe*" # Windscribe - "*mullvad*" # Mullvad - "*privateinternetaccess*" # PIA + "*nordvpn*" + "*expressvpn*" + "*protonvpn*" + "*surfshark*" + "*windscribe*" + "*mullvad*" + "*privateinternetaccess*" - # Screensaver & Dynamic Wallpaper - "*Aerial*" # Aerial screensaver (all case variants) - "*aerial*" # Aerial lowercase - "*Fliqlo*" # Fliqlo screensaver (all case variants) - "*fliqlo*" # Fliqlo lowercase + # Screensaver & Wallpaper + "*Aerial*" + "*aerial*" + "*Fliqlo*" + "*fliqlo*" - # Development Tools - Git & Version Control - "com.github.GitHubDesktop" # GitHub Desktop - "com.sublimemerge" # Sublime Merge - "com.torusknot.SourceTreeNotMAS" # SourceTree - "com.git-tower.Tower*" # Tower - "com.gitfox.GitFox" # GitFox - "com.github.Gitify" # Gitify - "com.fork.Fork" # Fork - "com.axosoft.gitkraken" # GitKraken + # Git & Version Control + "com.github.GitHubDesktop" + "com.sublimemerge" + "com.torusknot.SourceTreeNotMAS" + "com.git-tower.Tower*" + "com.gitfox.GitFox" + "com.github.Gitify" + "com.fork.Fork" + "com.axosoft.gitkraken" - # Development Tools - Terminal & Shell - "com.googlecode.iterm2" # iTerm2 - "net.kovidgoyal.kitty" # Kitty - "io.alacritty" # Alacritty - "com.github.wez.wezterm" # WezTerm - "com.hyper.Hyper" # Hyper - "com.mizage.divvy" # Divvy - "com.fig.Fig" # Fig (terminal assistant) - "dev.warp.Warp-Stable" # Warp - "com.termius-dmg" # Termius (SSH client) + # Terminal & Shell + "com.googlecode.iterm2" + "net.kovidgoyal.kitty" + "io.alacritty" + "com.github.wez.wezterm" + "com.hyper.Hyper" + "com.mizage.divvy" + "com.fig.Fig" + "dev.warp.Warp-Stable" + "com.termius-dmg" - # Development Tools - Docker & Virtualization - "com.docker.docker" # Docker Desktop - "com.getutm.UTM" # UTM - "com.vmware.fusion" # VMware Fusion - "com.parallels.desktop.*" # Parallels Desktop - "org.virtualbox.app.VirtualBox" # VirtualBox - "com.vagrant.*" # Vagrant - "com.orbstack.OrbStack" # OrbStack + # Docker & Virtualization + "com.docker.docker" + "com.getutm.UTM" + "com.vmware.fusion" + "com.parallels.desktop.*" + "org.virtualbox.app.VirtualBox" + "com.vagrant.*" + "com.orbstack.OrbStack" - # System Monitoring & Performance - "com.bjango.istatmenus*" # iStat Menus - "eu.exelban.Stats" # Stats - "com.monitorcontrol.*" # MonitorControl - "com.bresink.system-toolkit.*" # TinkerTool System - "com.mediaatelier.MenuMeters" # MenuMeters - "com.activity-indicator.app" # Activity Indicator - "net.cindori.sensei" # Sensei + # System Monitoring + "com.bjango.istatmenus*" + "eu.exelban.Stats" + "com.monitorcontrol.*" + "com.bresink.system-toolkit.*" + "com.mediaatelier.MenuMeters" + "com.activity-indicator.app" + "net.cindori.sensei" - # Window Management & Productivity - "com.macitbetter.*" # BetterTouchTool, BetterSnapTool - "com.hegenberg.*" # BetterTouchTool legacy - "com.manytricks.*" # Moom, Witch, Name Mangler, Resolutionator - "com.divisiblebyzero.*" # Spectacle - "com.koingdev.*" # Koingg apps - "com.if.Amphetamine" # Amphetamine - "com.lwouis.alt-tab-macos" # AltTab - "net.matthewpalmer.Vanilla" # Vanilla - "com.lightheadsw.Caffeine" # Caffeine - "com.contextual.Contexts" # Contexts - "com.amethyst.Amethyst" # Amethyst - "com.knollsoft.Rectangle" # Rectangle - "com.knollsoft.Hookshot" # Hookshot - "com.surteesstudios.Bartender" # Bartender - "com.gaosun.eul" # eul (system monitor) - "com.pointum.hazeover" # HazeOver + # Window Management + "com.macitbetter.*" # BetterTouchTool, BetterSnapTool + "com.hegenberg.*" + "com.manytricks.*" # Moom, Witch, etc. + "com.divisiblebyzero.*" + "com.koingdev.*" + "com.if.Amphetamine" + "com.lwouis.alt-tab-macos" + "net.matthewpalmer.Vanilla" + "com.lightheadsw.Caffeine" + "com.contextual.Contexts" + "com.amethyst.Amethyst" + "com.knollsoft.Rectangle" + "com.knollsoft.Hookshot" + "com.surteesstudios.Bartender" + "com.gaosun.eul" + "com.pointum.hazeover" # Launcher & Automation - "com.runningwithcrayons.Alfred" # Alfred - "com.raycast.macos" # Raycast - "com.blacktree.Quicksilver" # Quicksilver - "com.stairways.keyboardmaestro.*" # Keyboard Maestro - "com.manytricks.Butler" # Butler - "com.happenapps.Quitter" # Quitter - "com.pilotmoon.scroll-reverser" # Scroll Reverser - "org.pqrs.Karabiner-Elements" # Karabiner-Elements - "com.apple.Automator" # Automator (system, but keep user workflows) + "com.runningwithcrayons.Alfred" + "com.raycast.macos" + "com.blacktree.Quicksilver" + "com.stairways.keyboardmaestro.*" + "com.manytricks.Butler" + "com.happenapps.Quitter" + "com.pilotmoon.scroll-reverser" + "org.pqrs.Karabiner-Elements" + "com.apple.Automator" - # Note-Taking & Documentation - "com.bear-writer.*" # Bear - "com.typora.*" # Typora - "com.ulyssesapp.*" # Ulysses - "com.literatureandlatte.*" # Scrivener - "com.dayoneapp.*" # Day One - "notion.id" # Notion - "md.obsidian" # Obsidian - "com.logseq.logseq" # Logseq - "com.evernote.Evernote" # Evernote - "com.onenote.mac" # OneNote - "com.omnigroup.OmniOutliner*" # OmniOutliner - "net.shinyfrog.bear" # Bear legacy - "com.goodnotes.GoodNotes" # GoodNotes - "com.marginnote.MarginNote*" # MarginNote - "com.roamresearch.*" # Roam Research - "com.reflect.ReflectApp" # Reflect - "com.inkdrop.*" # Inkdrop + # Note-Taking + "com.bear-writer.*" + "com.typora.*" + "com.ulyssesapp.*" + "com.literatureandlatte.*" + "com.dayoneapp.*" + "notion.id" + "md.obsidian" + "com.logseq.logseq" + "com.evernote.Evernote" + "com.onenote.mac" + "com.omnigroup.OmniOutliner*" + "net.shinyfrog.bear" + "com.goodnotes.GoodNotes" + "com.marginnote.MarginNote*" + "com.roamresearch.*" + "com.reflect.ReflectApp" + "com.inkdrop.*" - # Design & Creative Tools - "com.adobe.*" # Adobe Creative Suite - "com.bohemiancoding.*" # Sketch - "com.figma.*" # Figma - "com.framerx.*" # Framer - "com.zeplin.*" # Zeplin - "com.invisionapp.*" # InVision - "com.principle.*" # Principle - "com.pixelmatorteam.*" # Pixelmator - "com.affinitydesigner.*" # Affinity Designer - "com.affinityphoto.*" # Affinity Photo - "com.affinitypublisher.*" # Affinity Publisher - "com.linearity.curve" # Linearity Curve - "com.canva.CanvaDesktop" # Canva - "com.maxon.cinema4d" # Cinema 4D - "com.autodesk.*" # Autodesk products - "com.sketchup.*" # SketchUp + # Design & Creative + "com.adobe.*" + "com.bohemiancoding.*" + "com.figma.*" + "com.framerx.*" + "com.zeplin.*" + "com.invisionapp.*" + "com.principle.*" + "com.pixelmatorteam.*" + "com.affinitydesigner.*" + "com.affinityphoto.*" + "com.affinitypublisher.*" + "com.linearity.curve" + "com.canva.CanvaDesktop" + "com.maxon.cinema4d" + "com.autodesk.*" + "com.sketchup.*" - # Communication & Collaboration - "com.tencent.xinWeChat" # WeChat (Chinese users) - "com.tencent.qq" # QQ - "com.alibaba.DingTalkMac" # DingTalk - "com.alibaba.AliLang.osx" # AliLang (retain login/config data) - "com.alibaba.alilang3.osx.ShipIt" # AliLang updater component - "com.alibaba.AlilangMgr.QueryNetworkInfo" # AliLang network helper - "us.zoom.xos" # Zoom - "com.microsoft.teams*" # Microsoft Teams - "com.slack.Slack" # Slack - "com.hnc.Discord" # Discord - "app.legcord.Legcord" # Legcord - "org.telegram.desktop" # Telegram - "ru.keepcoder.Telegram" # Telegram legacy - "net.whatsapp.WhatsApp" # WhatsApp - "com.skype.skype" # Skype - "com.cisco.webexmeetings" # Webex - "com.ringcentral.RingCentral" # RingCentral - "com.readdle.smartemail-Mac" # Spark Email - "com.airmail.*" # Airmail - "com.postbox-inc.postbox" # Postbox - "com.tinyspeck.slackmacgap" # Slack legacy + # Communication + "com.tencent.xinWeChat" + "com.tencent.qq" + "com.alibaba.DingTalkMac" + "com.alibaba.AliLang.osx" + "com.alibaba.alilang3.osx.ShipIt" + "com.alibaba.AlilangMgr.QueryNetworkInfo" + "us.zoom.xos" + "com.microsoft.teams*" + "com.slack.Slack" + "com.hnc.Discord" + "app.legcord.Legcord" + "org.telegram.desktop" + "ru.keepcoder.Telegram" + "net.whatsapp.WhatsApp" + "com.skype.skype" + "com.cisco.webexmeetings" + "com.ringcentral.RingCentral" + "com.readdle.smartemail-Mac" + "com.airmail.*" + "com.postbox-inc.postbox" + "com.tinyspeck.slackmacgap" - # Task Management & Productivity - "com.omnigroup.OmniFocus*" # OmniFocus - "com.culturedcode.*" # Things - "com.todoist.*" # Todoist - "com.any.do.*" # Any.do - "com.ticktick.*" # TickTick - "com.microsoft.to-do" # Microsoft To Do - "com.trello.trello" # Trello - "com.asana.nativeapp" # Asana - "com.clickup.*" # ClickUp - "com.monday.desktop" # Monday.com - "com.airtable.airtable" # Airtable - "com.notion.id" # Notion (also note-taking) - "com.linear.linear" # Linear + # Task Management + "com.omnigroup.OmniFocus*" + "com.culturedcode.*" + "com.todoist.*" + "com.any.do.*" + "com.ticktick.*" + "com.microsoft.to-do" + "com.trello.trello" + "com.asana.nativeapp" + "com.clickup.*" + "com.monday.desktop" + "com.airtable.airtable" + "com.notion.id" + "com.linear.linear" # File Transfer & Sync - "com.panic.transmit*" # Transmit (FTP/SFTP) - "com.binarynights.ForkLift*" # ForkLift - "com.noodlesoft.Hazel" # Hazel - "com.cyberduck.Cyberduck" # Cyberduck - "io.filezilla.FileZilla" # FileZilla - "com.apple.Xcode.CloudDocuments" # Xcode Cloud Documents - "com.synology.*" # Synology apps + "com.panic.transmit*" + "com.binarynights.ForkLift*" + "com.noodlesoft.Hazel" + "com.cyberduck.Cyberduck" + "io.filezilla.FileZilla" + "com.apple.Xcode.CloudDocuments" + "com.synology.*" - # Cloud Storage & Backup (Issue #204) - "com.dropbox.*" # Dropbox - "com.getdropbox.*" # Dropbox legacy - "*dropbox*" # Dropbox helpers/updaters - "ws.agile.*" # 1Password sync helpers - "com.backblaze.*" # Backblaze - "*backblaze*" # Backblaze helpers - "com.box.desktop*" # Box - "*box.desktop*" # Box helpers - "com.microsoft.OneDrive*" # Microsoft OneDrive - "com.microsoft.SyncReporter" # OneDrive sync reporter - "*OneDrive*" # OneDrive helpers/updaters - "com.google.GoogleDrive" # Google Drive - "com.google.keystone*" # Google updaters (Drive, Chrome, etc.) - "*GoogleDrive*" # Google Drive helpers - "com.amazon.drive" # Amazon Drive - "com.apple.bird" # iCloud Drive daemon - "com.apple.CloudDocs*" # iCloud Documents - "com.displaylink.*" # DisplayLink - "com.fujitsu.pfu.ScanSnap*" # ScanSnap - "com.citrix.*" # Citrix Workspace - "org.xquartz.*" # XQuartz - "us.zoom.updater*" # Zoom updaters - "com.DigiDNA.iMazing*" # iMazing - "com.shirtpocket.*" # SuperDuper backup - "homebrew.mxcl.*" # Homebrew services + # Cloud Storage & Backup + "com.dropbox.*" + "com.getdropbox.*" + "*dropbox*" + "ws.agile.*" + "com.backblaze.*" + "*backblaze*" + "com.box.desktop*" + "*box.desktop*" + "com.microsoft.OneDrive*" + "com.microsoft.SyncReporter" + "*OneDrive*" + "com.google.GoogleDrive" + "com.google.keystone*" + "*GoogleDrive*" + "com.amazon.drive" + "com.apple.bird" + "com.apple.CloudDocs*" + "com.displaylink.*" + "com.fujitsu.pfu.ScanSnap*" + "com.citrix.*" + "org.xquartz.*" + "us.zoom.updater*" + "com.DigiDNA.iMazing*" + "com.shirtpocket.*" + "homebrew.mxcl.*" # Screenshot & Recording - "com.cleanshot.*" # CleanShot X - "com.xnipapp.xnip" # Xnip - "com.reincubate.camo" # Camo - "com.tunabellysoftware.ScreenFloat" # ScreenFloat - "net.telestream.screenflow*" # ScreenFlow - "com.techsmith.snagit*" # Snagit - "com.techsmith.camtasia*" # Camtasia - "com.obsidianapp.screenrecorder" # Screen Recorder - "com.kap.Kap" # Kap - "com.getkap.*" # Kap legacy - "com.linebreak.CloudApp" # CloudApp - "com.droplr.droplr-mac" # Droplr + "com.cleanshot.*" + "com.xnipapp.xnip" + "com.reincubate.camo" + "com.tunabellysoftware.ScreenFloat" + "net.telestream.screenflow*" + "com.techsmith.snagit*" + "com.techsmith.camtasia*" + "com.obsidianapp.screenrecorder" + "com.kap.Kap" + "com.getkap.*" + "com.linebreak.CloudApp" + "com.droplr.droplr-mac" # Media & Entertainment - "com.spotify.client" # Spotify - "com.apple.Music" # Apple Music - "com.apple.podcasts" # Apple Podcasts - "com.apple.BKAgentService" # Apple Books (Agent) - "com.apple.iBooksX" # Apple Books - "com.apple.iBooks" # Apple Books (Legacy) - "com.apple.FinalCutPro" # Final Cut Pro - "com.apple.Motion" # Motion - "com.apple.Compressor" # Compressor - "com.blackmagic-design.*" # DaVinci Resolve - "com.colliderli.iina" # IINA - "org.videolan.vlc" # VLC - "io.mpv" # MPV - "com.noodlesoft.Hazel" # Hazel (automation) - "tv.plex.player.desktop" # Plex - "com.netease.163music" # NetEase Music + "com.spotify.client" + "com.apple.Music" + "com.apple.podcasts" + "com.apple.BKAgentService" + "com.apple.iBooksX" + "com.apple.iBooks" + "com.blackmagic-design.*" + "com.colliderli.iina" + "org.videolan.vlc" + "io.mpv" + "tv.plex.player.desktop" + "com.netease.163music" - # Web Browsers (protect complex storage like IndexedDB, localStorage) - "Firefox" # Firefox Application Support - "org.mozilla.*" # Firefox bundle IDs + # Web Browsers + "Firefox" + "org.mozilla.*" - # License Management & App Stores - "com.paddle.Paddle*" # Paddle (license management) - "com.setapp.DesktopClient" # Setapp - "com.devmate.*" # DevMate (license framework) - "org.sparkle-project.Sparkle" # Sparkle (update framework) + # License & App Stores + "com.paddle.Paddle*" + "com.setapp.DesktopClient" + "com.devmate.*" + "org.sparkle-project.Sparkle" ) # Centralized check for critical system components (case-insensitive) @@ -470,11 +548,22 @@ bundle_matches_pattern() { # Check if application is a protected system component should_protect_from_uninstall() { local bundle_id="$1" - for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do + + # First check if it's an uninstallable Apple app + # These apps have com.apple.* bundle IDs but are NOT system-critical + for pattern in "${APPLE_UNINSTALLABLE_APPS[@]}"; do if bundle_matches_pattern "$bundle_id" "$pattern"; then - return 0 + return 1 # Can be uninstalled fi done + + # Then check system-critical components + for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do + if bundle_matches_pattern "$bundle_id" "$pattern"; then + return 0 # Protected + fi + done + return 1 } @@ -582,7 +671,13 @@ should_protect_path() { # This catches things like /Users/tw93/Library/Caches/Claude when pattern is *Claude* # 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 + # Uninstall mode: first check if it's an uninstallable Apple app + for pattern in "${APPLE_UNINSTALLABLE_APPS[@]}"; do + if bundle_matches_pattern "$path" "$pattern"; then + return 1 # Can be uninstalled + fi + done + # Then check system-critical components for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do if bundle_matches_pattern "$path" "$pattern"; then return 0 diff --git a/tests/core_common.bats b/tests/core_common.bats index 5b2d88e..10eee55 100644 --- a/tests/core_common.bats +++ b/tests/core_common.bats @@ -160,6 +160,32 @@ EOF [ "$result" = "protected" ] } +@test "Apple apps from App Store can be uninstalled (Issue #386)" { + # Xcode should NOT be protected from uninstall + result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.dt.Xcode' && echo 'protected' || echo 'not-protected'") + [ "$result" = "not-protected" ] + + # Final Cut Pro should NOT be protected from uninstall + result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.FinalCutPro' && echo 'protected' || echo 'not-protected'") + [ "$result" = "not-protected" ] + + # GarageBand should NOT be protected from uninstall + result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.GarageBand' && echo 'protected' || echo 'not-protected'") + [ "$result" = "not-protected" ] + + # iWork apps should NOT be protected from uninstall + result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.iWork.Pages' && echo 'protected' || echo 'not-protected'") + [ "$result" = "not-protected" ] + + # But Safari (system app) should still be protected + result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.Safari' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + + # And Finder should still be protected + result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.finder' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] +} + @test "print_summary_block formats output correctly" { result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; print_summary_block 'success' 'Test Summary' 'Detail 1' 'Detail 2'") [[ "$result" == *"Test Summary"* ]] From 89dcb0c3b56de5e09c5693e65650e1e9a8e64d42 Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 30 Jan 2026 15:06:30 +0800 Subject: [PATCH 16/21] fix: Use `du -P` for accurate size calculation and add timeouts to channel sends to prevent blocking. --- cmd/analyze/scanner.go | 37 ++++++++++++++++++++++++++++++------- lib/clean/apps.sh | 6 +++--- lib/clean/brew.sh | 2 +- lib/clean/project.sh | 2 +- lib/clean/user.sh | 2 +- lib/core/file_ops.sh | 4 ++-- 6 files changed, 38 insertions(+), 15 deletions(-) diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index 539fdb0..0d2bb6c 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -119,12 +119,16 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in size := getActualFileSize(fullPath, info) atomic.AddInt64(&total, size) - entryChan <- dirEntry{ + select { + case entryChan <- dirEntry{ Name: child.Name() + " →", Path: fullPath, Size: size, IsDir: isDir, LastAccess: getLastAccessTimeFromInfo(info), + }: + case <-time.After(100 * time.Millisecond): + // Skip if channel is blocked } continue } @@ -158,12 +162,15 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) - entryChan <- dirEntry{ + select { + case entryChan <- dirEntry{ Name: name, Path: path, Size: size, IsDir: true, LastAccess: time.Time{}, + }: + case <-time.After(100 * time.Millisecond): } }(child.Name(), fullPath) continue @@ -188,12 +195,15 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) - entryChan <- dirEntry{ + select { + case entryChan <- dirEntry{ Name: name, Path: path, Size: size, IsDir: true, LastAccess: time.Time{}, + }: + case <-time.After(100 * time.Millisecond): } }(child.Name(), fullPath) continue @@ -209,12 +219,15 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) - entryChan <- dirEntry{ + select { + case entryChan <- dirEntry{ Name: name, Path: path, Size: size, IsDir: true, LastAccess: time.Time{}, + }: + case <-time.After(100 * time.Millisecond): } }(child.Name(), fullPath) continue @@ -230,18 +243,25 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(filesScanned, 1) atomic.AddInt64(bytesScanned, size) - entryChan <- dirEntry{ + select { + case entryChan <- dirEntry{ Name: child.Name(), Path: fullPath, Size: size, IsDir: false, LastAccess: getLastAccessTimeFromInfo(info), + }: + case <-time.After(100 * time.Millisecond): } + // Track large files only. if !shouldSkipFileForLargeTracking(fullPath) { minSize := atomic.LoadInt64(&largeFileMinSize) if size >= minSize { - largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size} + select { + case largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}: + case <-time.After(100 * time.Millisecond): + } } } } @@ -516,7 +536,10 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar if !shouldSkipFileForLargeTracking(fullPath) && largeFileMinSize != nil { minSize := atomic.LoadInt64(largeFileMinSize) if size >= minSize { - largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size} + select { + case largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}: + case <-time.After(100 * time.Millisecond): + } } } diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index 9fffe40..e9f193c 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -413,7 +413,7 @@ clean_orphaned_system_services() { fi orphaned_files+=("$plist") local size_kb - size_kb=$(sudo du -sk "$plist" 2> /dev/null | awk '{print $1}' || echo "0") + size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0") ((total_orphaned_kb += size_kb)) ((orphaned_count++)) break @@ -444,7 +444,7 @@ clean_orphaned_system_services() { fi orphaned_files+=("$plist") local size_kb - size_kb=$(sudo du -sk "$plist" 2> /dev/null | awk '{print $1}' || echo "0") + size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0") ((total_orphaned_kb += size_kb)) ((orphaned_count++)) break @@ -474,7 +474,7 @@ clean_orphaned_system_services() { fi orphaned_files+=("$helper") local size_kb - size_kb=$(sudo du -sk "$helper" 2> /dev/null | awk '{print $1}' || echo "0") + size_kb=$(sudo du -skP "$helper" 2> /dev/null | awk '{print $1}' || echo "0") ((total_orphaned_kb += size_kb)) ((orphaned_count++)) break diff --git a/lib/clean/brew.sh b/lib/clean/brew.sh index 6ccf2d6..89f930c 100644 --- a/lib/clean/brew.sh +++ b/lib/clean/brew.sh @@ -29,7 +29,7 @@ clean_homebrew() { local skip_cleanup=false local brew_cache_size=0 if [[ -d ~/Library/Caches/Homebrew ]]; then - brew_cache_size=$(run_with_timeout 3 du -sk ~/Library/Caches/Homebrew 2> /dev/null | awk '{print $1}') + brew_cache_size=$(run_with_timeout 3 du -skP ~/Library/Caches/Homebrew 2> /dev/null | awk '{print $1}') local du_exit=$? if [[ $du_exit -eq 0 && -n "$brew_cache_size" && "$brew_cache_size" -lt 51200 ]]; then skip_cleanup=true diff --git a/lib/clean/project.sh b/lib/clean/project.sh index e1ffa25..0774324 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -489,7 +489,7 @@ is_recently_modified() { get_dir_size_kb() { local path="$1" if [[ -d "$path" ]]; then - du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0" + du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0" else echo "0" fi diff --git a/lib/clean/user.sh b/lib/clean/user.sh index a67658f..46d9870 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -626,7 +626,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 -shP "$backup_dir" 2> /dev/null | awk '{print $1}') if [[ -n "$backup_human" ]]; then note_activity echo -e " ${YELLOW}${ICON_WARNING}${NC} iOS backups: ${GREEN}${backup_human}${NC}${GRAY}, Path: $backup_dir${NC}" diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 126fcf0..920c45b 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -267,7 +267,7 @@ safe_sudo_remove() { 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 -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0") if [[ "$size_kb" -gt 0 ]]; then file_size=$(bytes_to_human "$((size_kb * 1024))") fi @@ -297,7 +297,7 @@ safe_sudo_remove() { 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") + size_kb=$(sudo du -skP "$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 From e81be160315412a3e5e10f9d1d4aa2fc510790f3 Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 30 Jan 2026 15:37:13 +0800 Subject: [PATCH 17/21] perf: optimize scanner timer usage and app protection matching - Replace time.After() with reusable timer to reduce GC pressure - Use pre-compiled regex for app bundle matching (O(1) vs O(N)) - Fix Bash 3.2 compatibility (remove local -n usage) --- cmd/analyze/scanner.go | 77 ++++++++++++++++++++++++++++++++++---- lib/core/app_protection.sh | 68 ++++++++++++++++++++++++--------- 2 files changed, 120 insertions(+), 25 deletions(-) diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index 0d2bb6c..982d497 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -119,6 +119,16 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in size := getActualFileSize(fullPath, info) atomic.AddInt64(&total, size) + // Reuse timer to reduce GC pressure + timer := time.NewTimer(0) + // Ensure timer is drained immediately since we start with 0 + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + select { case entryChan <- dirEntry{ Name: child.Name() + " →", @@ -127,10 +137,26 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in IsDir: isDir, LastAccess: getLastAccessTimeFromInfo(info), }: - case <-time.After(100 * time.Millisecond): - // Skip if channel is blocked + default: + // If channel is full, use timer to wait with timeout + timer.Reset(100 * time.Millisecond) + select { + case entryChan <- dirEntry{ + Name: child.Name() + " →", + Path: fullPath, + Size: size, + IsDir: isDir, + LastAccess: getLastAccessTimeFromInfo(info), + }: + if !timer.Stop() { + <-timer.C + } + case <-timer.C: + // Skip if channel is blocked + } } continue + } if child.IsDir() { @@ -162,6 +188,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) + timer := time.NewTimer(100 * time.Millisecond) select { case entryChan <- dirEntry{ Name: name, @@ -170,7 +197,10 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in IsDir: true, LastAccess: time.Time{}, }: - case <-time.After(100 * time.Millisecond): + if !timer.Stop() { + <-timer.C + } + case <-timer.C: } }(child.Name(), fullPath) continue @@ -195,6 +225,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) + timer := time.NewTimer(100 * time.Millisecond) select { case entryChan <- dirEntry{ Name: name, @@ -203,7 +234,10 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in IsDir: true, LastAccess: time.Time{}, }: - case <-time.After(100 * time.Millisecond): + if !timer.Stop() { + <-timer.C + } + case <-timer.C: } }(child.Name(), fullPath) continue @@ -219,6 +253,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) + timer := time.NewTimer(100 * time.Millisecond) select { case entryChan <- dirEntry{ Name: name, @@ -227,7 +262,10 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in IsDir: true, LastAccess: time.Time{}, }: - case <-time.After(100 * time.Millisecond): + if !timer.Stop() { + <-timer.C + } + case <-timer.C: } }(child.Name(), fullPath) continue @@ -243,6 +281,9 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(filesScanned, 1) atomic.AddInt64(bytesScanned, size) + // Single-use timer for main loop (less pressure than tight loop above) + // But let's be consistent and optimized + timer := time.NewTimer(100 * time.Millisecond) select { case entryChan <- dirEntry{ Name: child.Name(), @@ -251,16 +292,23 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in IsDir: false, LastAccess: getLastAccessTimeFromInfo(info), }: - case <-time.After(100 * time.Millisecond): + if !timer.Stop() { + <-timer.C + } + case <-timer.C: } // Track large files only. if !shouldSkipFileForLargeTracking(fullPath) { minSize := atomic.LoadInt64(&largeFileMinSize) if size >= minSize { + timer.Reset(100 * time.Millisecond) select { case largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}: - case <-time.After(100 * time.Millisecond): + if !timer.Stop() { + <-timer.C + } + case <-timer.C: } } } @@ -471,6 +519,15 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar maxConcurrent := min(runtime.NumCPU()*2, maxDirWorkers) sem := make(chan struct{}, maxConcurrent) + // Reuse timer for large file sends + timer := time.NewTimer(0) + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + for _, child := range children { fullPath := filepath.Join(root, child.Name()) @@ -536,9 +593,13 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar if !shouldSkipFileForLargeTracking(fullPath) && largeFileMinSize != nil { minSize := atomic.LoadInt64(largeFileMinSize) if size >= minSize { + timer.Reset(100 * time.Millisecond) select { case largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}: - case <-time.After(100 * time.Millisecond): + if !timer.Stop() { + <-timer.C + } + case <-timer.C: } } } diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 7fae723..e260037 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -545,24 +545,53 @@ bundle_matches_pattern() { return 1 } +# Helper to build regex from array (Bash 3.2 compatible - no namerefs) +# $1: Variable name to store result +# $2...: Array elements (passed as expanded list) +build_regex_var() { + local var_name="$1" + shift + local regex="" + for pattern in "$@"; do + # Escape dots . -> \. + local p="${pattern//./\\.}" + # Convert * to .* + p="${p//\*/.*}" + # Start and end anchors + p="^${p}$" + + if [[ -z "$regex" ]]; then + regex="$p" + else + regex="$regex|$p" + fi + done + eval "$var_name=\"\$regex\"" +} + +# Generate Regex strings once +APPLE_UNINSTALLABLE_REGEX="" +build_regex_var APPLE_UNINSTALLABLE_REGEX "${APPLE_UNINSTALLABLE_APPS[@]}" + +SYSTEM_CRITICAL_REGEX="" +build_regex_var SYSTEM_CRITICAL_REGEX "${SYSTEM_CRITICAL_BUNDLES[@]}" + +DATA_PROTECTED_REGEX="" +build_regex_var DATA_PROTECTED_REGEX "${DATA_PROTECTED_BUNDLES[@]}" + # Check if application is a protected system component should_protect_from_uninstall() { local bundle_id="$1" # First check if it's an uninstallable Apple app - # These apps have com.apple.* bundle IDs but are NOT system-critical - for pattern in "${APPLE_UNINSTALLABLE_APPS[@]}"; do - if bundle_matches_pattern "$bundle_id" "$pattern"; then - return 1 # Can be uninstalled - fi - done + if [[ "$bundle_id" =~ $APPLE_UNINSTALLABLE_REGEX ]]; then + return 1 # Can be uninstalled + fi # Then check system-critical components - for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do - if bundle_matches_pattern "$bundle_id" "$pattern"; then - return 0 # Protected - fi - done + if [[ "$bundle_id" =~ $SYSTEM_CRITICAL_REGEX ]]; then + return 0 # Protected + fi return 1 } @@ -570,12 +599,17 @@ should_protect_from_uninstall() { # Check if application data should be protected during cleanup should_protect_data() { local bundle_id="$1" - # Protect both system critical and data protected bundles during cleanup - for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do - if bundle_matches_pattern "$bundle_id" "$pattern"; then - return 0 - fi - done + + # Check system critical + if [[ "$bundle_id" =~ $SYSTEM_CRITICAL_REGEX ]]; then + return 0 + fi + + # Check data protected + if [[ "$bundle_id" =~ $DATA_PROTECTED_REGEX ]]; then + return 0 + fi + return 1 } From 1e650d8144bd09d82258b986837878c7ac0cd820 Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 30 Jan 2026 18:16:31 +0800 Subject: [PATCH 18/21] fix: use \033[2K to fully clear spinner lines and prevent text remnants Fixes text remnants showing when spinner messages change from longer to shorter text. Changed from \033[K (clear to end of line) to \033[2K (clear entire line) in stop_section_spinner(), safe_clear_line(), and safe_clear_lines() functions. Fixes #390 --- lib/core/base.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/core/base.sh b/lib/core/base.sh index 0f0170c..17a579e 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -628,7 +628,7 @@ start_section_spinner() { stop_section_spinner() { stop_inline_spinner 2> /dev/null || true if [[ -t 1 ]]; then - echo -ne "\r\033[K" >&2 || true + echo -ne "\r\033[2K" >&2 || true fi } @@ -646,7 +646,7 @@ safe_clear_lines() { # Clear lines one by one (more reliable than multi-line sequences) local i for ((i = 0; i < lines; i++)); do - printf "\033[1A\r\033[K" > "$tty_device" 2> /dev/null || return 1 + printf "\033[1A\r\033[2K" > "$tty_device" 2> /dev/null || return 1 done return 0 @@ -660,7 +660,7 @@ safe_clear_line() { # Use centralized ANSI support check is_ansi_supported 2> /dev/null || return 1 - printf "\r\033[K" > "$tty_device" 2> /dev/null || return 1 + printf "\r\033[2K" > "$tty_device" 2> /dev/null || return 1 return 0 } From e12a40f6bf12de391ad9a07b285b157798a0e37e Mon Sep 17 00:00:00 2001 From: tw93 Date: Fri, 30 Jan 2026 18:16:31 +0800 Subject: [PATCH 19/21] fix: use \033[2K to fully clear spinner lines and prevent text remnants Fixes text remnants and extra blank lines when spinner messages change. Issues fixed: 1. Text remnants when switching from longer to shorter messages (e.g., 'Cleaning...ems...') 2. Extra blank lines appearing after section headers Root causes: - \033[K only clears from cursor to end of line, leaving remnants when new messages are shorter - stop_section_spinner was clearing lines even when no spinner was running Changes: - lib/core/base.sh: - Changed stop_section_spinner(), safe_clear_line(), safe_clear_lines() to use \033[2K - Added guard in stop_section_spinner to only clear when spinner is actually running - lib/core/ui.sh: - Clear line once when starting spinner (before loop) to ensure clean start - Normal spinner rotation uses \r without clearing (performance optimization) Performance: Line clearing happens only once per spinner start, not on every loop iteration. Fixes #390 --- bin/clean.sh | 4 ---- lib/core/base.sh | 13 ++++++++----- lib/core/ui.sh | 3 +++ 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/bin/clean.sh b/bin/clean.sh index dede154..6d11602 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -142,10 +142,6 @@ cleanup() { stop_inline_spinner 2> /dev/null || true - if [[ -t 1 ]]; then - printf "\r\033[K" >&2 || true - fi - cleanup_temp_files stop_sudo_session diff --git a/lib/core/base.sh b/lib/core/base.sh index 0f0170c..8622f65 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -626,9 +626,12 @@ start_section_spinner() { # Stop spinner and clear the line # Usage: stop_section_spinner stop_section_spinner() { - stop_inline_spinner 2> /dev/null || true - if [[ -t 1 ]]; then - echo -ne "\r\033[K" >&2 || true + # Only clear line if spinner was actually running + if [[ -n "${INLINE_SPINNER_PID:-}" ]]; then + stop_inline_spinner 2> /dev/null || true + if [[ -t 1 ]]; then + echo -ne "\r\033[2K" >&2 || true + fi fi } @@ -646,7 +649,7 @@ safe_clear_lines() { # Clear lines one by one (more reliable than multi-line sequences) local i for ((i = 0; i < lines; i++)); do - printf "\033[1A\r\033[K" > "$tty_device" 2> /dev/null || return 1 + printf "\033[1A\r\033[2K" > "$tty_device" 2> /dev/null || return 1 done return 0 @@ -660,7 +663,7 @@ safe_clear_line() { # Use centralized ANSI support check is_ansi_supported 2> /dev/null || return 1 - printf "\r\033[K" > "$tty_device" 2> /dev/null || return 1 + printf "\r\033[2K" > "$tty_device" 2> /dev/null || return 1 return 0 } diff --git a/lib/core/ui.sh b/lib/core/ui.sh index ef44d92..536082b 100755 --- a/lib/core/ui.sh +++ b/lib/core/ui.sh @@ -301,6 +301,9 @@ start_inline_spinner() { [[ -z "$chars" ]] && chars="|/-\\" local i=0 + # Clear line on first output to prevent text remnants from previous messages + printf "\r\033[2K" >&2 || true + # Cooperative exit: check for stop file instead of relying on signals while [[ ! -f "$stop_file" ]]; do local c="${chars:$((i % ${#chars})):1}" From 9d2c907c08aebf5ebd2c977a1e1680b9bbe61b02 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 31 Jan 2026 11:31:51 +0800 Subject: [PATCH 20/21] perf: optimize app protection with dual-array strategy and lazy loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SYSTEM_CRITICAL_BUNDLES_FAST (30 items) for clean operations - Keep SYSTEM_CRITICAL_BUNDLES (60+ items) for uninstall precision - Implement lazy regex loading - only build when uninstall is triggered - Use fast wildcard matching for should_protect_data (clean scenario) - Use detailed regex matching for should_protect_from_uninstall Performance improvement: - Clean command: 71s → 35s (51% faster) - System CPU: 17.3s → 5.9s (66% reduction) - Now 27% faster than v1.23.2 baseline (48s → 35s) Fixes performance regression introduced in commit 2865a78 where SYSTEM_CRITICAL_BUNDLES expanded from 5 wildcards to 60+ explicit entries. --- lib/core/app_protection.sh | 79 +++++++++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 18 deletions(-) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index e260037..83c037e 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -19,6 +19,46 @@ fi # Application Management +# ============================================================================ +# Performance Note: +# - SYSTEM_CRITICAL_BUNDLES_FAST: Fast wildcard patterns for cleanup operations +# - SYSTEM_CRITICAL_BUNDLES: Detailed list for uninstall protection (lazy-loaded) +# ============================================================================ + +# Fast patterns for cleanup operations (used by should_protect_data) +# These wildcards provide adequate protection with minimal performance impact +readonly SYSTEM_CRITICAL_BUNDLES_FAST=( + "com.apple.*" + "loginwindow" + "dock" + "systempreferences" + "finder" + "safari" + "backgroundtaskmanagement*" + "keychain*" + "security*" + "bluetooth*" + "wifi*" + "network*" + "tcc" + "notification*" + "accessibility*" + "universalaccess*" + "HIToolbox*" + "textinput*" + "TextInput*" + "keyboard*" + "Keyboard*" + "inputsource*" + "InputSource*" + "keylayout*" + "KeyLayout*" + "GlobalPreferences" + ".GlobalPreferences" + "org.pqrs.Karabiner*" +) + +# Detailed list for uninstall protection # Critical system components protected from uninstallation # Note: We explicitly list system components instead of using "com.apple.*" wildcard # to allow uninstallation of user-installed Apple apps (Xcode, Final Cut Pro, etc.) @@ -569,28 +609,29 @@ build_regex_var() { eval "$var_name=\"\$regex\"" } -# Generate Regex strings once +# Lazy-loaded regex for uninstall operations (only built when needed) APPLE_UNINSTALLABLE_REGEX="" -build_regex_var APPLE_UNINSTALLABLE_REGEX "${APPLE_UNINSTALLABLE_APPS[@]}" - SYSTEM_CRITICAL_REGEX="" -build_regex_var SYSTEM_CRITICAL_REGEX "${SYSTEM_CRITICAL_BUNDLES[@]}" -DATA_PROTECTED_REGEX="" -build_regex_var DATA_PROTECTED_REGEX "${DATA_PROTECTED_BUNDLES[@]}" +_ensure_uninstall_regex() { + if [[ -z "$SYSTEM_CRITICAL_REGEX" ]]; then + build_regex_var APPLE_UNINSTALLABLE_REGEX "${APPLE_UNINSTALLABLE_APPS[@]}" + build_regex_var SYSTEM_CRITICAL_REGEX "${SYSTEM_CRITICAL_BUNDLES[@]}" + fi +} # Check if application is a protected system component should_protect_from_uninstall() { local bundle_id="$1" - # First check if it's an uninstallable Apple app + _ensure_uninstall_regex + if [[ "$bundle_id" =~ $APPLE_UNINSTALLABLE_REGEX ]]; then - return 1 # Can be uninstalled + return 1 fi - # Then check system-critical components if [[ "$bundle_id" =~ $SYSTEM_CRITICAL_REGEX ]]; then - return 0 # Protected + return 0 fi return 1 @@ -600,15 +641,17 @@ should_protect_from_uninstall() { should_protect_data() { local bundle_id="$1" - # Check system critical - if [[ "$bundle_id" =~ $SYSTEM_CRITICAL_REGEX ]]; then - return 0 - fi + for pattern in "${SYSTEM_CRITICAL_BUNDLES_FAST[@]}"; do + if bundle_matches_pattern "$bundle_id" "$pattern"; then + return 0 + fi + done - # Check data protected - if [[ "$bundle_id" =~ $DATA_PROTECTED_REGEX ]]; then - return 0 - fi + for pattern in "${DATA_PROTECTED_BUNDLES[@]}"; do + if bundle_matches_pattern "$bundle_id" "$pattern"; then + return 0 + fi + done return 1 } From 15bb60c531f10660c48381458c8e83b77afd960a Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 31 Jan 2026 11:54:26 +0800 Subject: [PATCH 21/21] perf: fix should_protect_data performance regression with case optimization Issue #393 reported mo clean hanging on 'Scanning sandboxed apps' and 'Scanning orphaned app resources'. Root cause: should_protect_data() was looping through 332 patterns (28 SYSTEM_CRITICAL_BUNDLES_FAST + 304 DATA_PROTECTED_BUNDLES). For 662 sandboxed containers, this resulted in 220,000+ pattern matches. Solution: Replace loops with fast case statement for common prefixes: - com.apple.* (system apps) - instant return - com.microsoft.*, com.jetbrains.* (IDEs) - instant return - Password managers, VPNs, Docker etc. - instant return - Other apps - instant return (no protection needed) - Only check detailed list for special wildcards (com.tencent.* etc.) Performance: Clean command maintains 35s (same as previous optimization) Functionality: All 9 protection tests pass --- lib/core/app_protection.sh | 63 ++++++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 83c037e..5bca60e 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -609,9 +609,11 @@ build_regex_var() { eval "$var_name=\"\$regex\"" } -# Lazy-loaded regex for uninstall operations (only built when needed) +# Lazy-loaded regex (only built when needed) APPLE_UNINSTALLABLE_REGEX="" SYSTEM_CRITICAL_REGEX="" +SYSTEM_CRITICAL_FAST_REGEX="" +DATA_PROTECTED_REGEX="" _ensure_uninstall_regex() { if [[ -z "$SYSTEM_CRITICAL_REGEX" ]]; then @@ -620,6 +622,13 @@ _ensure_uninstall_regex() { fi } +_ensure_data_protection_regex() { + if [[ -z "$SYSTEM_CRITICAL_FAST_REGEX" ]]; then + build_regex_var SYSTEM_CRITICAL_FAST_REGEX "${SYSTEM_CRITICAL_BUNDLES_FAST[@]}" + build_regex_var DATA_PROTECTED_REGEX "${DATA_PROTECTED_BUNDLES[@]}" + fi +} + # Check if application is a protected system component should_protect_from_uninstall() { local bundle_id="$1" @@ -641,18 +650,52 @@ should_protect_from_uninstall() { should_protect_data() { local bundle_id="$1" - for pattern in "${SYSTEM_CRITICAL_BUNDLES_FAST[@]}"; do - if bundle_matches_pattern "$bundle_id" "$pattern"; then + case "$bundle_id" in + com.apple.* | loginwindow | dock | systempreferences | finder | safari) return 0 - fi - done - - for pattern in "${DATA_PROTECTED_BUNDLES[@]}"; do - if bundle_matches_pattern "$bundle_id" "$pattern"; then + ;; + backgroundtaskmanagement* | keychain* | security* | bluetooth* | wifi* | network* | tcc) return 0 - fi - done + ;; + notification* | accessibility* | universalaccess* | HIToolbox*) + return 0 + ;; + *inputmethod* | *InputMethod* | *IME | textinput* | TextInput*) + return 0 + ;; + keyboard* | Keyboard* | inputsource* | InputSource* | keylayout* | KeyLayout*) + return 0 + ;; + GlobalPreferences | .GlobalPreferences | org.pqrs.Karabiner*) + return 0 + ;; + com.1password.* | com.agilebits.* | com.lastpass.* | com.dashlane.* | com.bitwarden.*) + return 0 + ;; + com.jetbrains.* | JetBrains* | com.microsoft.* | com.visualstudio.*) + return 0 + ;; + com.sublimetext.* | com.sublimehq.* | Cursor | Claude | ChatGPT | Ollama) + return 0 + ;; + com.nssurge.* | com.v2ray.* | ClashX* | Surge* | Shadowrocket* | Quantumult*) + return 0 + ;; + com.docker.* | com.getpostman.* | com.insomnia.*) + return 0 + ;; + com.tencent.* | com.sogou.* | com.baidu.* | com.googlecode.* | im.rime.*) + # These might have wildcards, check detailed list + for pattern in "${DATA_PROTECTED_BUNDLES[@]}"; do + if bundle_matches_pattern "$bundle_id" "$pattern"; then + return 0 + fi + done + return 1 + ;; + esac + # Most apps won't match, return early return 1 }