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/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 diff --git a/bin/clean.sh b/bin/clean.sh index 0c789b0..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 @@ -601,7 +597,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/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index 5a0cde3..982d497 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -119,14 +119,44 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in size := getActualFileSize(fullPath, info) atomic.AddInt64(&total, size) - entryChan <- dirEntry{ + // 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() + " →", Path: fullPath, Size: size, IsDir: isDir, LastAccess: getLastAccessTimeFromInfo(info), + }: + 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() { @@ -158,12 +188,19 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) - entryChan <- dirEntry{ + timer := time.NewTimer(100 * time.Millisecond) + select { + case entryChan <- dirEntry{ Name: name, Path: path, Size: size, IsDir: true, LastAccess: time.Time{}, + }: + if !timer.Stop() { + <-timer.C + } + case <-timer.C: } }(child.Name(), fullPath) continue @@ -188,12 +225,19 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) - entryChan <- dirEntry{ + timer := time.NewTimer(100 * time.Millisecond) + select { + case entryChan <- dirEntry{ Name: name, Path: path, Size: size, IsDir: true, LastAccess: time.Time{}, + }: + if !timer.Stop() { + <-timer.C + } + case <-timer.C: } }(child.Name(), fullPath) continue @@ -209,12 +253,19 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) - entryChan <- dirEntry{ + timer := time.NewTimer(100 * time.Millisecond) + select { + case entryChan <- dirEntry{ Name: name, Path: path, Size: size, IsDir: true, LastAccess: time.Time{}, + }: + if !timer.Stop() { + <-timer.C + } + case <-timer.C: } }(child.Name(), fullPath) continue @@ -230,18 +281,35 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(filesScanned, 1) atomic.AddInt64(bytesScanned, size) - entryChan <- dirEntry{ + // 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(), Path: fullPath, Size: size, IsDir: false, LastAccess: getLastAccessTimeFromInfo(info), + }: + if !timer.Stop() { + <-timer.C + } + case <-timer.C: } + // Track large files only. if !shouldSkipFileForLargeTracking(fullPath) { minSize := atomic.LoadInt64(&largeFileMinSize) if size >= minSize { - largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size} + timer.Reset(100 * time.Millisecond) + select { + case largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}: + if !timer.Stop() { + <-timer.C + } + case <-timer.C: + } } } } @@ -451,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()) @@ -516,7 +593,14 @@ 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} + timer.Reset(100 * time.Millisecond) + select { + case largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}: + if !timer.Stop() { + <-timer.C + } + case <-timer.C: + } } } @@ -584,7 +668,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/cmd/status/metrics_health_test.go b/cmd/status/metrics_health_test.go index e9c9205..b88df18 100644 --- a/cmd/status/metrics_health_test.go +++ b/cmd/status/metrics_health_test.go @@ -86,20 +86,14 @@ func TestColorizeTempThresholds(t *testing.T) { } func TestColorizeTempStyleRanges(t *testing.T) { - // Test that different temperature ranges use different styles - // We can't easily test the exact style applied, but we can verify - // the function returns consistent results for each range - normalTemp := colorizeTemp(40.0) warningTemp := colorizeTemp(65.0) dangerTemp := colorizeTemp(85.0) - // All should be non-empty and contain the formatted value if normalTemp == "" || warningTemp == "" || dangerTemp == "" { t.Fatal("colorizeTemp should not return empty strings") } - // Verify formatting precision (one decimal place) if !strings.Contains(normalTemp, "40.0") { t.Errorf("normal temp should contain '40.0', got: %s", normalTemp) } @@ -110,3 +104,93 @@ func TestColorizeTempStyleRanges(t *testing.T) { t.Errorf("danger temp should contain '85.0', got: %s", dangerTemp) } } + +func TestCalculateHealthScoreEdgeCases(t *testing.T) { + tests := []struct { + name string + cpu CPUStatus + mem MemoryStatus + disks []DiskStatus + diskIO DiskIOStatus + thermal ThermalStatus + wantMin int + wantMax int + }{ + { + name: "all metrics at normal threshold", + cpu: CPUStatus{Usage: 30.0}, + mem: MemoryStatus{UsedPercent: 50.0}, + disks: []DiskStatus{{UsedPercent: 70.0}}, + diskIO: DiskIOStatus{ReadRate: 25.0, WriteRate: 25.0}, + thermal: ThermalStatus{CPUTemp: 60.0}, + wantMin: 95, + wantMax: 100, + }, + { + name: "memory pressure warning only", + cpu: CPUStatus{Usage: 10.0}, + mem: MemoryStatus{UsedPercent: 40.0, Pressure: "warn"}, + disks: []DiskStatus{{UsedPercent: 40.0}}, + diskIO: DiskIOStatus{ReadRate: 5.0, WriteRate: 5.0}, + thermal: ThermalStatus{CPUTemp: 40.0}, + wantMin: 90, + wantMax: 100, + }, + { + name: "empty disks array", + cpu: CPUStatus{Usage: 10.0}, + mem: MemoryStatus{UsedPercent: 30.0}, + disks: []DiskStatus{}, + diskIO: DiskIOStatus{ReadRate: 5.0, WriteRate: 5.0}, + thermal: ThermalStatus{CPUTemp: 40.0}, + wantMin: 95, + wantMax: 100, + }, + { + name: "zero thermal data", + cpu: CPUStatus{Usage: 10.0}, + mem: MemoryStatus{UsedPercent: 30.0}, + disks: []DiskStatus{{UsedPercent: 40.0}}, + diskIO: DiskIOStatus{ReadRate: 5.0, WriteRate: 5.0}, + thermal: ThermalStatus{CPUTemp: 0}, + wantMin: 95, + wantMax: 100, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score, _ := calculateHealthScore(tt.cpu, tt.mem, tt.disks, tt.diskIO, tt.thermal) + if score < tt.wantMin || score > tt.wantMax { + t.Errorf("calculateHealthScore() = %d, want range [%d, %d]", score, tt.wantMin, tt.wantMax) + } + }) + } +} + +func TestFormatUptimeEdgeCases(t *testing.T) { + tests := []struct { + name string + secs uint64 + want string + }{ + {"zero seconds", 0, "0m"}, + {"59 seconds", 59, "0m"}, + {"one minute exact", 60, "1m"}, + {"59 minutes 59 seconds", 3599, "59m"}, + {"one hour exact", 3600, "1h 0m"}, + {"one day exact", 86400, "1d 0h"}, + {"one day one hour", 90000, "1d 1h"}, + {"multiple days no hours", 172800, "2d 0h"}, + {"large uptime", 31536000, "365d 0h"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatUptime(tt.secs) + if got != tt.want { + t.Errorf("formatUptime(%d) = %q, want %q", tt.secs, got, tt.want) + } + }) + } +} diff --git a/cmd/status/view_test.go b/cmd/status/view_test.go index 49990c1..ba67327 100644 --- a/cmd/status/view_test.go +++ b/cmd/status/view_test.go @@ -1,6 +1,9 @@ package main -import "testing" +import ( + "strings" + "testing" +) func TestFormatRate(t *testing.T) { tests := []struct { @@ -39,6 +42,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 @@ -113,6 +205,173 @@ 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) + } + }) + } +} + func TestParseInt(t *testing.T) { tests := []struct { name string @@ -333,3 +592,389 @@ func TestParsePMSet(t *testing.T) { }) } } + +func TestProgressBar(t *testing.T) { + tests := []struct { + name string + percent float64 + wantRune int + }{ + {"zero percent", 0, 16}, + {"negative clamped", -10, 16}, + {"low percent", 25, 16}, + {"half", 50, 16}, + {"high percent", 75, 16}, + {"full", 100, 16}, + {"over 100 clamped", 150, 16}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := progressBar(tt.percent) + if len(got) == 0 { + t.Errorf("progressBar(%v) returned empty string", tt.percent) + return + } + gotClean := stripANSI(got) + gotRuneCount := len([]rune(gotClean)) + if gotRuneCount != tt.wantRune { + t.Errorf("progressBar(%v) rune count = %d, want %d", tt.percent, gotRuneCount, tt.wantRune) + } + }) + } +} + +func TestBatteryProgressBar(t *testing.T) { + tests := []struct { + name string + percent float64 + wantRune int + }{ + {"zero percent", 0, 16}, + {"negative clamped", -10, 16}, + {"critical low", 15, 16}, + {"low", 25, 16}, + {"medium", 50, 16}, + {"high", 75, 16}, + {"full", 100, 16}, + {"over 100 clamped", 120, 16}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := batteryProgressBar(tt.percent) + if len(got) == 0 { + t.Errorf("batteryProgressBar(%v) returned empty string", tt.percent) + return + } + gotClean := stripANSI(got) + gotRuneCount := len([]rune(gotClean)) + if gotRuneCount != tt.wantRune { + t.Errorf("batteryProgressBar(%v) rune count = %d, want %d", tt.percent, gotRuneCount, tt.wantRune) + } + }) + } +} + +func TestColorizeTemp(t *testing.T) { + tests := []struct { + name string + temp float64 + }{ + {"very low", 20.0}, + {"low", 40.0}, + {"normal threshold", 55.9}, + {"at warn threshold", 56.0}, + {"warn range", 65.0}, + {"just below danger", 75.9}, + {"at danger threshold", 76.0}, + {"high", 85.0}, + {"very high", 95.0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := colorizeTemp(tt.temp) + if got == "" { + t.Errorf("colorizeTemp(%v) returned empty string", tt.temp) + } + }) + } +} + +func TestIoBar(t *testing.T) { + tests := []struct { + name string + rate float64 + }{ + {"zero", 0}, + {"very low", 5}, + {"low normal", 20}, + {"at warn threshold", 30}, + {"warn range", 50}, + {"just below danger", 79}, + {"at danger threshold", 80}, + {"high", 100}, + {"very high", 200}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ioBar(tt.rate) + if got == "" { + t.Errorf("ioBar(%v) returned empty string", tt.rate) + return + } + gotClean := stripANSI(got) + gotRuneCount := len([]rune(gotClean)) + if gotRuneCount != 5 { + t.Errorf("ioBar(%v) rune count = %d, want 5", tt.rate, gotRuneCount) + } + }) + } +} + +func TestMiniBar(t *testing.T) { + tests := []struct { + name string + percent float64 + }{ + {"zero", 0}, + {"negative", -5}, + {"low", 15}, + {"at first step", 20}, + {"mid", 50}, + {"high", 75}, + {"full", 100}, + {"over 100", 120}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := miniBar(tt.percent) + if got == "" { + t.Errorf("miniBar(%v) returned empty string", tt.percent) + return + } + gotClean := stripANSI(got) + gotRuneCount := len([]rune(gotClean)) + if gotRuneCount != 5 { + t.Errorf("miniBar(%v) rune count = %d, want 5", tt.percent, gotRuneCount) + } + }) + } +} + +func TestFormatDiskLine(t *testing.T) { + tests := []struct { + name string + label string + disk DiskStatus + }{ + { + name: "empty label defaults to DISK", + label: "", + disk: DiskStatus{UsedPercent: 50.5, Used: 100 << 30, Total: 200 << 30}, + }, + { + name: "internal disk", + label: "INTR", + disk: DiskStatus{UsedPercent: 67.2, Used: 336 << 30, Total: 500 << 30}, + }, + { + name: "external disk", + label: "EXTR1", + disk: DiskStatus{UsedPercent: 85.0, Used: 850 << 30, Total: 1000 << 30}, + }, + { + name: "low usage", + label: "INTR", + disk: DiskStatus{UsedPercent: 15.3, Used: 15 << 30, Total: 100 << 30}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatDiskLine(tt.label, tt.disk) + if got == "" { + t.Errorf("formatDiskLine(%q, ...) returned empty string", tt.label) + return + } + expectedLabel := tt.label + if expectedLabel == "" { + expectedLabel = "DISK" + } + if !contains(got, expectedLabel) { + t.Errorf("formatDiskLine(%q, ...) = %q, should contain label %q", tt.label, got, expectedLabel) + } + }) + } +} + +func TestGetScoreStyle(t *testing.T) { + tests := []struct { + name string + score int + }{ + {"critical low", 10}, + {"poor low", 25}, + {"just below fair", 39}, + {"at fair threshold", 40}, + {"fair range", 50}, + {"just below good", 59}, + {"at good threshold", 60}, + {"good range", 70}, + {"just below excellent", 74}, + {"at excellent threshold", 75}, + {"excellent range", 85}, + {"just below perfect", 89}, + {"perfect", 90}, + {"max", 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + style := getScoreStyle(tt.score) + if style.GetForeground() == nil { + t.Errorf("getScoreStyle(%d) returned style with no foreground color", tt.score) + } + }) + } +} + +func TestMaxInt(t *testing.T) { + tests := []struct { + name string + a int + b int + want int + }{ + {"a greater", 10, 5, 10}, + {"b greater", 3, 8, 8}, + {"equal", 7, 7, 7}, + {"negative a greater", -5, -10, -5}, + {"negative b greater", -10, -5, -5}, + {"zero vs positive", 0, 5, 5}, + {"zero vs negative", 0, -5, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := maxInt(tt.a, tt.b) + if got != tt.want { + t.Errorf("maxInt(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestSparkline(t *testing.T) { + tests := []struct { + name string + history []float64 + current float64 + width int + wantLen int + }{ + { + name: "empty history", + history: []float64{}, + current: 1.5, + width: 10, + wantLen: 10, + }, + { + name: "short history padded", + history: []float64{1.0, 2.0, 3.0}, + current: 3.0, + width: 10, + wantLen: 10, + }, + { + name: "exact width", + history: []float64{1.0, 2.0, 3.0, 4.0, 5.0}, + current: 5.0, + width: 5, + wantLen: 5, + }, + { + name: "history longer than width", + history: []float64{1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0}, + current: 10.0, + width: 5, + wantLen: 5, + }, + { + name: "low current value ok style", + history: []float64{1.0, 1.5, 2.0}, + current: 2.0, + width: 5, + wantLen: 5, + }, + { + name: "medium current value warn style", + history: []float64{3.0, 4.0, 5.0}, + current: 5.0, + width: 5, + wantLen: 5, + }, + { + name: "high current value danger style", + history: []float64{8.0, 9.0, 10.0}, + current: 10.0, + width: 5, + wantLen: 5, + }, + { + name: "all identical values flatline", + history: []float64{5.0, 5.0, 5.0, 5.0, 5.0}, + current: 5.0, + width: 5, + wantLen: 5, + }, + { + name: "zero width edge case", + history: []float64{1.0, 2.0, 3.0}, + current: 2.0, + width: 0, + wantLen: 0, + }, + { + name: "width of 1", + history: []float64{1.0, 2.0, 3.0}, + current: 2.0, + width: 1, + wantLen: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sparkline(tt.history, tt.current, tt.width) + if tt.width == 0 { + return + } + if got == "" { + t.Errorf("sparkline() returned empty string") + return + } + gotClean := stripANSI(got) + if len([]rune(gotClean)) != tt.wantLen { + t.Errorf("sparkline() rune length = %d, want %d", len([]rune(gotClean)), tt.wantLen) + } + }) + } +} + +func stripANSI(s string) string { + var result strings.Builder + i := 0 + for i < len(s) { + if i < len(s)-1 && s[i] == '\x1b' && s[i+1] == '[' { + i += 2 + for i < len(s) && (s[i] < 'A' || s[i] > 'Z') && (s[i] < 'a' || s[i] > 'z') { + i++ + } + if i < len(s) { + i++ + } + } else { + result.WriteByte(s[i]) + i++ + } + } + return result.String() +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr))) +} + +func containsMiddle(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} 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 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 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" 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/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/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 48c2be4..662fd07 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" @@ -25,7 +25,7 @@ clean_user_essentials() { done < <(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) fi else - echo -e " ${GRAY}${ICON_EMPTY}${NC} Trash · already empty" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty" fi fi } @@ -628,7 +628,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/app_protection.sh b/lib/core/app_protection.sh index 446a478..5bca60e 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -19,21 +19,133 @@ fi # Application Management -# Critical system components protected from uninstallation -readonly SYSTEM_CRITICAL_BUNDLES=( - "com.apple.*" # System essentials +# ============================================================================ +# 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.) +readonly SYSTEM_CRITICAL_BUNDLES=( + # 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.SystemSettings*" "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" "backgroundtaskmanagementagent" "keychain*" "security*" @@ -55,11 +167,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 +197,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) @@ -467,26 +585,117 @@ 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\"" +} + +# 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 + build_regex_var APPLE_UNINSTALLABLE_REGEX "${APPLE_UNINSTALLABLE_APPS[@]}" + build_regex_var SYSTEM_CRITICAL_REGEX "${SYSTEM_CRITICAL_BUNDLES[@]}" + 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" - for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do - if bundle_matches_pattern "$bundle_id" "$pattern"; then - return 0 - fi - done + + _ensure_uninstall_regex + + if [[ "$bundle_id" =~ $APPLE_UNINSTALLABLE_REGEX ]]; then + return 1 + fi + + if [[ "$bundle_id" =~ $SYSTEM_CRITICAL_REGEX ]]; then + return 0 + fi + return 1 } # 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 + + case "$bundle_id" in + com.apple.* | loginwindow | dock | systempreferences | finder | safari) return 0 - fi - done + ;; + backgroundtaskmanagement* | keychain* | security* | bluetooth* | wifi* | network* | tcc) + return 0 + ;; + 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 } @@ -582,7 +791,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 @@ -670,9 +885,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 +946,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 +1092,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 +1122,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/lib/core/base.sh b/lib/core/base.sh index 3c9e4c0..8622f65 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" @@ -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/file_ops.sh b/lib/core/file_ops.sh index 33a9126..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 @@ -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 diff --git a/lib/core/log.sh b/lib/core/log.sh index cc933cd..797b92f 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,10 +71,16 @@ 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') + 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 @@ -76,38 +90,43 @@ log_info() { # Log success message log_success() { echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1" - local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + 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 fi } -# Log warning message +# shellcheck disable=SC2329 log_warning() { echo -e "${YELLOW}$1${NC}" - local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + 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 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') + 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 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,7 +158,7 @@ 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)" @@ -154,7 +173,7 @@ log_operation_session_start() { local command="${1:-mole}" local timestamp - timestamp=$(date '+%Y-%m-%d %H:%M:%S') + timestamp=$(get_timestamp) { echo "" @@ -162,8 +181,7 @@ log_operation_session_start() { } >> "$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,7 +189,7 @@ 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 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}" 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"* ]] 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 new file mode 100644 index 0000000..efd687f --- /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"$ ]] +}