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"$ ]]
+}