diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index da1854d..aa38925 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -27,20 +27,25 @@ jobs: ~/Library/Caches/Homebrew /usr/local/Cellar/shfmt /usr/local/Cellar/shellcheck - key: ${{ runner.os }}-brew-quality-${{ hashFiles('**/Brewfile') }} + /usr/local/Cellar/golangci-lint + key: ${{ runner.os }}-brew-quality-v2-${{ hashFiles('**/Brewfile') }} restore-keys: | - ${{ runner.os }}-brew-quality- + ${{ runner.os }}-brew-quality-v2- - name: Install tools - run: brew install shfmt shellcheck + run: brew install shfmt shellcheck golangci-lint - name: Set up Go uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5 with: go-version: '1.24.6' + - name: Install goimports + run: go install golang.org/x/tools/cmd/goimports@latest + - name: Format all code run: | + export PATH=$(go env GOPATH)/bin:$PATH ./scripts/check.sh --format - name: Commit formatting changes @@ -75,12 +80,18 @@ jobs: ~/Library/Caches/Homebrew /usr/local/Cellar/shfmt /usr/local/Cellar/shellcheck - key: ${{ runner.os }}-brew-quality-${{ hashFiles('**/Brewfile') }} + /usr/local/Cellar/golangci-lint + key: ${{ runner.os }}-brew-quality-v2-${{ hashFiles('**/Brewfile') }} restore-keys: | - ${{ runner.os }}-brew-quality- + ${{ runner.os }}-brew-quality-v2- - name: Install tools - run: brew install shfmt shellcheck + run: brew install shfmt shellcheck golangci-lint + + - name: Set up Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5 + with: + go-version: '1.24.6' - name: Run check script run: ./scripts/check.sh --no-format diff --git a/.github/workflows/update-contributors.yml b/.github/workflows/update-contributors.yml index ea691a7..7934087 100644 --- a/.github/workflows/update-contributors.yml +++ b/.github/workflows/update-contributors.yml @@ -25,26 +25,38 @@ jobs: fetch-depth: 0 - name: Generate contributors SVG - uses: wow-actions/contributors-list@v1 + uses: tw93/contributors-list@master with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} svgPath: CONTRIBUTORS.svg - svgWidth: 1210 - avatarMargin: 12 + svgWidth: 1000 + avatarSize: 72 + avatarMargin: 45 userNameHeight: 20 + noFetch: false noCommit: true + truncate: 0 includeBots: false excludeUsers: "github-actions web-flow dependabot claude" itemTemplate: | - - - {{{ name }}} + + + + + + + + {{{ name }}} - name: Commit & Push - uses: stefanzweifel/git-auto-commit-action@v5 + uses: stefanzweifel/git-auto-commit-action@v7 with: commit_message: "chore: update contributors [skip ci]" file_pattern: CONTRIBUTORS.svg + commit_user_name: github-actions[bot] + commit_user_email: 41898282+github-actions[bot]@users.noreply.github.com + commit_author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> + push_options: '--force' diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..299e526 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,43 @@ +# golangci-lint configuration for Mole +# https://golangci-lint.run/usage/configuration/ + +version: "2" + +run: + timeout: 5m + # Only lint Go code in cmd directory + modules-download-mode: readonly + +linters: + enable: + # Default linters + - govet + - staticcheck + - errcheck + - ineffassign + - unused + + settings: + govet: + enable-all: true + disable: + - shadow + - fieldalignment # struct field alignment optimization is noisy + errcheck: + exclude-functions: + - (io.Closer).Close + - (*os/exec.Cmd).Run + - (*os/exec.Cmd).Start + staticcheck: + checks: ["all", "-QF1003", "-SA9003"] + + exclusions: + rules: + # Ignore certain patterns in test files + - path: _test\.go + linters: + - errcheck + # Ignore errors from os.Remove in cleanup code + - text: "os.Remove" + linters: + - errcheck diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c2654a..007ecd4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,10 @@ ```bash # Install development tools -brew install shfmt shellcheck bats-core +brew install shfmt shellcheck bats-core golangci-lint + +# Install goimports for better Go formatting +go install golang.org/x/tools/cmd/goimports@latest ``` ## Development diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg new file mode 100644 index 0000000..a51f7f9 --- /dev/null +++ b/CONTRIBUTORS.svg @@ -0,0 +1,323 @@ + + + + + + + + + + + + tw93 + + + + + + + + + + + JackPhallen + + + + + + + + + + + amanthanvi + + + + + + + + + + + rubnogueira + + + + + + + + + + + bsisduck + + + + + + + + + + + alexandear + + + + + + + + + + + jimmystridh + + + + + + + + + + + fte-jjmartres + + + + + + + + + + + Else00 + + + + + + + + + + + carolyn-sun + + + + + + + + + + + purofle + + + + + + + + + + + huyixi + + + + + + + + + + + bunizao + + + + + + + + + + + zeldrisho + + + + + + + + + + + yuzeguitarist + + + + + + + + + + + thijsvanhal + + + + + + + + + + + Sizk + + + + + + + + + + + ndbroadbent + + + + + + + + + + + MohammedEsafi + + + + + + + + + + + Schlauer-Hax + + + + + + + + + + + anonymort + + + + + + + + + + + khipu-luke + + + + + + + + + + + LmanTW + + + + + + + + + + + kwakubiney + + + + + + + + + + + kowyo + + + + + + + + + + + jalen0x + + + + + + + + + + + Hensell + + + + + + + + + + + ClathW + + + + + + + + + + + andmev + + + \ No newline at end of file diff --git a/README.md b/README.md index f6327a1..a174545 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,12 @@

- Mole - 95.50GB freed + Mole - 95.50GB freed

## Features -- **Unified toolkit**: Consolidated features of CleanMyMac, AppCleaner, DaisyDisk, and iStat into a **single binary** +- **All-in-one toolkit**: CleanMyMac, AppCleaner, DaisyDisk, and iStat Menus combined into a **single binary** - **Deep cleaning**: Scans and removes caches, logs, and browser leftovers to **reclaim gigabytes of space** - **Smart uninstaller**: Thoroughly removes apps along with launch agents, preferences, and **hidden remnants** - **Disk insights**: Visualizes usage, manages large files, **rebuilds caches**, and refreshes system services @@ -26,16 +26,16 @@ ## Quick Start -**Install by Brew, recommended:** +**Install via Homebrew — recommended:** ```bash brew install mole ``` -**or by Script, for older macOS or latest code:** +**Or via script:** ```bash -# Add '-s latest' for newest, '-s dev' for development, or '-s 1.17.0' for a version. +# Optional args: -s latest for main branch code, -s 1.17.0 for specific version curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash ``` @@ -52,7 +52,7 @@ mo purge # Clean project build artifacts mo installer # Find and remove installer files mo touchid # Configure Touch ID for sudo -mo completion # Setup shell tab completion +mo completion # Set up shell tab completion mo update # Update Mole mo remove # Remove Mole from system mo --help # Show help @@ -71,13 +71,11 @@ mo purge --paths # Configure project scan directories ## Tips - **Terminal**: iTerm2 has known compatibility issues; we recommend Alacritty, kitty, WezTerm, Ghostty, or Warp. -- **Safety**: Built with strict protections. See our [Security Audit](SECURITY_AUDIT.md). Preview changes with `mo clean --dry-run`. -- **Whitelist**: Manage protected paths with `mo clean --whitelist`. -- **Touch ID**: Enable Touch ID for sudo commands by running `mo touchid`. -- **Shell Completion**: Enable tab completion by running `mo completion` (auto-detect and install). -- **Navigation**: Supports standard arrow keys and Vim bindings (`h/j/k/l`). -- **Debug**: View detailed logs by appending the `--debug` flag (e.g., `mo clean --debug`). -- **Detailed Preview**: Combine `--dry-run --debug` for comprehensive operation details including risk levels, file paths, sizes, and expected outcomes. Check `~/.config/mole/mole_debug_session.log` for full details. +- **Safety**: Built with strict protections. See [Security Audit](SECURITY_AUDIT.md). Preview changes with `mo clean --dry-run`. +- **Debug Mode**: Use `--debug` for detailed logs (e.g., `mo clean --debug`). Combine with `--dry-run` for comprehensive preview including risk levels and file details. +- **Navigation**: Supports arrow keys and Vim bindings (`h/j/k/l`). +- **Status Shortcuts**: In `mo status`, press `k` to toggle cat visibility and save preference, `q` to quit. +- **Configuration**: Run `mo touchid` for Touch ID sudo, `mo completion` for shell tab completion, `mo clean --whitelist` to manage protected paths. ## Features in Detail @@ -142,7 +140,7 @@ System: 5/32 GB RAM | 333/460 GB Disk (72%) | Uptime 6d System optimization completed ==================================================================== -Use `mo optimize --whitelist` to protect specific optimization items from being run. +Use `mo optimize --whitelist` to exclude specific optimizations. ``` ### Disk Space Analyzer @@ -209,7 +207,7 @@ Select Categories to Clean - 18.5GB (8 selected) ● backend-service 2.5GB | node_modules ``` -> **Use with caution:** This will permanently delete selected artifacts. Review carefully before confirming. Recent projects (< 7 days) are marked and unselected by default. +> **Use with caution:** This will permanently delete selected artifacts. Review carefully before confirming. Recent projects — less than 7 days old — are marked and unselected by default.
Custom Scan Paths @@ -222,7 +220,7 @@ Run `mo purge --paths` to configure which directories to scan, or edit `~/.confi ~/Work/ClientB ``` -When custom paths are configured, only those directories are scanned. Otherwise, defaults to `~/Projects`, `~/GitHub`, `~/dev`, etc. +When custom paths are configured, only those directories are scanned. Otherwise, it defaults to `~/Projects`, `~/GitHub`, `~/dev`, etc.
@@ -251,36 +249,34 @@ Launch Mole commands instantly from Raycast or Alfred: curl -fsSL https://raw.githubusercontent.com/tw93/Mole/main/scripts/setup-quick-launchers.sh | bash ``` -Adds 5 commands: `clean`, `uninstall`, `optimize`, `analyze`, `status`. Mole automatically detects your terminal, or you can set `MO_LAUNCHER_APP=` to override. For Raycast, if this is your first script directory, add it in Raycast Extensions (Add Script Directory) and then run "Reload Script Directories" to load the new commands. +Adds 5 commands: `clean`, `uninstall`, `optimize`, `analyze`, `status`. + +Mole automatically detects your terminal, or set `MO_LAUNCHER_APP=` to override. For Raycast users: if this is your first script directory, add it via Raycast Extensions → Add Script Directory, then run "Reload Script Directories". ## Community Love -

- Community feedback on Mole -

+Mole wouldn't be possible without these amazing contributors. They've built countless features that make Mole what it is today. Go follow them! ❤️ -Users from around the world are loving Mole! Join the community and share your experience. - -## Developers - -Mole's development can not be without these Hackers. They contributed a lot of capabilities for Mole. Also, welcome to follow them! ❤️ - - - Contributors + + +Join thousands of users worldwide who trust Mole to keep their Macs clean and optimized. + +Community feedback on Mole + ## Support -
-Sponsorship and Community -
- -
- -- If Mole saved you space, consider starring the repo or sharing it with friends who need a cleaner Mac. +- If Mole saved you disk space, consider starring the repo or [sharing it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Mole&text=Mole%20-%20Deep%20clean%20and%20optimize%20your%20Mac.) with friends. - Have ideas or fixes? Check our [Contributing Guide](CONTRIBUTING.md), then open an issue or PR to help shape Mole's future. -- Love cats? Treat Tangyuan and Cola to canned food via this link to keep our mascots purring. +- Love Mole? Buy Tw93 an ice-cold Coke to keep the project alive and kicking! 🥤 + +
+Friends who bought me Coke +
+ +
## License -MIT License - feel free to enjoy and participate in open source. +MIT License — feel free to enjoy and participate in open source. diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 30bbc60..2fc4a61 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -4,7 +4,7 @@ **Security Audit & Compliance Report** -Version 1.17.0 | December 31, 2025 +Version 1.19.0 | January 5, 2026 --- @@ -33,7 +33,7 @@ Version 1.17.0 | December 31, 2025 |-----------|---------| | Audit Date | December 31, 2025 | | Audit Conclusion | **PASSED** | -| Mole Version | V1.17.0 | +| Mole Version | V1.19.0 | | Audited Branch | `main` (HEAD) | | Scope | Shell scripts, Go binaries, Configuration | | Methodology | Static analysis, Threat modeling, Code review | @@ -47,6 +47,7 @@ Version 1.17.0 | December 31, 2025 - Comprehensive protection for VPN, AI tools, and system components - Atomic operations with crash recovery mechanisms - Full user control with dry-run and whitelist capabilities +- Installer cleanup safely scans common locations with user confirmation --- @@ -290,7 +291,7 @@ bats tests/security.bats # Run specific suite | Standard | Implementation | |----------|----------------| | OWASP Secure Coding | Input validation, least privilege, defense-in-depth | -| CWE-22 (Path Traversal) | Absolute path enforcement, `../` rejection | +| CWE-22 (Path Traversal) | Enhanced detection: rejects `/../` components while allowing `..` in directory names (Firefox compatibility) | | CWE-78 (Command Injection) | Control character filtering | | CWE-59 (Link Following) | Symlink detection before privileged operations | | Apple File System Guidelines | Respects SIP, Read-Only Volumes, TCC | diff --git a/bin/clean.sh b/bin/clean.sh index 1586f4d..7beac0d 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -308,9 +308,8 @@ safe_clean() { return 0 fi - if [[ "${TRACK_SECTION:-0}" == "1" && "${SECTION_ACTIVITY:-0}" == "0" ]]; then - stop_section_spinner - fi + # Always stop spinner before outputting results + stop_section_spinner local description local -a targets diff --git a/bin/installer.sh b/bin/installer.sh index 73a8bd8..8eed412 100755 --- a/bin/installer.sh +++ b/bin/installer.sh @@ -205,12 +205,16 @@ collect_installers() { start_inline_spinner "Scanning for installers..." fi + # Start debug session + debug_operation_start "Collect Installers" "Scanning for redundant installer files" + # Scan all paths, deduplicate, and sort results local -a all_files=() while IFS= read -r file; do [[ -z "$file" ]] && continue all_files+=("$file") + debug_file_action "Found installer" "$file" done < <(scan_all_installers | sort -u) if [[ -t 1 ]]; then @@ -245,9 +249,20 @@ collect_installers() { local size_human size_human=$(bytes_to_human "$file_size") + # Get display filename - strip Homebrew hash prefix if present + local display_name + display_name=$(basename "$file") + if [[ "$source" == "Homebrew" ]]; then + # Homebrew names often look like: sha256--name--version + # Strip the leading hash if it matches [0-9a-f]{64}-- + if [[ "$display_name" =~ ^[0-9a-f]{64}--(.*) ]]; then + display_name="${BASH_REMATCH[1]}" + fi + fi + # Format display with alignment local display - display=$(format_installer_display "$(basename "$file")" "$size_human" "$source") + display=$(format_installer_display "$display_name" "$size_human" "$source") # Store installer data in parallel arrays INSTALLER_PATHS+=("$file") diff --git a/bin/uninstall.sh b/bin/uninstall.sh index f16e017..389e222 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -59,6 +59,24 @@ scan_applications() { local temp_file temp_file=$(create_temp_file) + # Local spinner_pid for cleanup + local spinner_pid="" + + # Trap to handle Ctrl+C during scan + local scan_interrupted=false + # shellcheck disable=SC2329 # Function invoked indirectly via trap + trap_scan_cleanup() { + scan_interrupted=true + if [[ -n "$spinner_pid" ]]; then + kill -TERM "$spinner_pid" 2> /dev/null || true + wait "$spinner_pid" 2> /dev/null || true + fi + printf "\r\033[K" >&2 + rm -f "$temp_file" "${temp_file}.sorted" "${temp_file}.progress" 2> /dev/null || true + exit 130 + } + trap trap_scan_cleanup INT + local current_epoch current_epoch=$(get_epoch_seconds) @@ -228,7 +246,6 @@ scan_applications() { local progress_file="${temp_file}.progress" echo "0" > "$progress_file" - local spinner_pid="" ( # shellcheck disable=SC2329 # Function invoked indirectly via trap cleanup_spinner() { exit 0; } diff --git a/cmd/analyze/cache.go b/cmd/analyze/cache.go index 8b70bf2..a0dcb7a 100644 --- a/cmd/analyze/cache.go +++ b/cmd/analyze/cache.go @@ -49,7 +49,7 @@ func cloneDirEntries(entries []dirEntry) []dirEntry { return nil } copied := make([]dirEntry, len(entries)) - copy(copied, entries) + copy(copied, entries) //nolint:all return copied } @@ -58,7 +58,7 @@ func cloneFileEntries(files []fileEntry) []fileEntry { return nil } copied := make([]fileEntry, len(files)) - copy(copied, files) + copy(copied, files) //nolint:all return copied } @@ -208,7 +208,7 @@ func loadCacheFromDisk(path string) (*cacheEntry, error) { if err != nil { return nil, err } - defer file.Close() + defer file.Close() //nolint:errcheck var entry cacheEntry decoder := gob.NewDecoder(file) @@ -258,7 +258,7 @@ func saveCacheToDisk(path string, result scanResult) error { if err != nil { return err } - defer file.Close() + defer file.Close() //nolint:errcheck encoder := gob.NewEncoder(file) return encoder.Encode(entry) diff --git a/cmd/analyze/format.go b/cmd/analyze/format.go index 7686745..5ef48d6 100644 --- a/cmd/analyze/format.go +++ b/cmd/analyze/format.go @@ -93,15 +93,12 @@ func humanizeBytes(size int64) string { return fmt.Sprintf("%.1f %cB", value, "KMGTPE"[exp]) } -func coloredProgressBar(value, max int64, percent float64) string { - if max <= 0 { +func coloredProgressBar(value, maxValue int64, percent float64) string { + if maxValue <= 0 { return colorGray + strings.Repeat("░", barWidth) + colorReset } - filled := int((value * int64(barWidth)) / max) - if filled > barWidth { - filled = barWidth - } + filled := min(int((value*int64(barWidth))/maxValue), barWidth) var barColor string if percent >= 50 { @@ -114,26 +111,27 @@ func coloredProgressBar(value, max int64, percent float64) string { barColor = colorGreen } - bar := barColor - for i := 0; i < barWidth; i++ { + var bar strings.Builder + bar.WriteString(barColor) + for i := range barWidth { if i < filled { if i < filled-1 { - bar += "█" + bar.WriteString("█") } else { - remainder := (value * int64(barWidth)) % max - if remainder > max/2 { - bar += "█" - } else if remainder > max/4 { - bar += "▓" + remainder := (value * int64(barWidth)) % maxValue + if remainder > maxValue/2 { + bar.WriteString("█") + } else if remainder > maxValue/4 { + bar.WriteString("▓") } else { - bar += "▒" + bar.WriteString("▒") } } } else { - bar += colorGray + "░" + barColor + bar.WriteString(colorGray + "░" + barColor) } } - return bar + colorReset + return bar.String() + colorReset } // runeWidth returns display width for wide characters and emoji. @@ -181,10 +179,6 @@ func calculateNameWidth(termWidth int) int { return available } -func trimName(name string) string { - return trimNameWithWidth(name, 45) // Default width for backward compatibility -} - func trimNameWithWidth(name string, maxWidth int) string { const ( ellipsis = "..." diff --git a/cmd/analyze/heap.go b/cmd/analyze/heap.go index 08bf8a9..0b4a5a5 100644 --- a/cmd/analyze/heap.go +++ b/cmd/analyze/heap.go @@ -7,11 +7,11 @@ func (h entryHeap) Len() int { return len(h) } func (h entryHeap) Less(i, j int) bool { return h[i].Size < h[j].Size } func (h entryHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } -func (h *entryHeap) Push(x interface{}) { +func (h *entryHeap) Push(x any) { *h = append(*h, x.(dirEntry)) } -func (h *entryHeap) Pop() interface{} { +func (h *entryHeap) Pop() any { old := *h n := len(old) x := old[n-1] @@ -26,11 +26,11 @@ func (h largeFileHeap) Len() int { return len(h) } func (h largeFileHeap) Less(i, j int) bool { return h[i].Size < h[j].Size } func (h largeFileHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } -func (h *largeFileHeap) Push(x interface{}) { +func (h *largeFileHeap) Push(x any) { *h = append(*h, x.(fileEntry)) } -func (h *largeFileHeap) Pop() interface{} { +func (h *largeFileHeap) Pop() any { old := *h n := len(old) x := old[n-1] diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index f81055b..e97500e 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -148,7 +148,7 @@ func main() { go prefetchOverviewCache(prefetchCtx) p := tea.NewProgram(newModel(abs, isOverview), tea.WithAltScreen()) - if err := p.Start(); err != nil { + if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "analyzer error: %v\n", err) os.Exit(1) } @@ -359,7 +359,7 @@ func (m model) scanCmd(path string) tea.Cmd { return scanResultMsg{result: result, err: nil} } - v, err, _ := scanGroup.Do(path, func() (interface{}, error) { + v, err, _ := scanGroup.Do(path, func() (any, error) { return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath) }) @@ -997,10 +997,7 @@ func (m *model) clampEntrySelection() { m.selected = 0 } viewport := calculateViewport(m.height, false) - maxOffset := len(m.entries) - viewport - if maxOffset < 0 { - maxOffset = 0 - } + maxOffset := max(len(m.entries)-viewport, 0) if m.offset > maxOffset { m.offset = maxOffset } @@ -1025,10 +1022,7 @@ func (m *model) clampLargeSelection() { m.largeSelected = 0 } viewport := calculateViewport(m.height, true) - maxOffset := len(m.largeFiles) - viewport - if maxOffset < 0 { - maxOffset = 0 - } + maxOffset := max(len(m.largeFiles)-viewport, 0) if m.largeOffset > maxOffset { m.largeOffset = maxOffset } diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index 9654e59..b6ab09b 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -39,10 +39,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in heap.Init(largeFilesHeap) // Worker pool sized for I/O-bound scanning. - numWorkers := runtime.NumCPU() * cpuMultiplier - if numWorkers < minWorkers { - numWorkers = minWorkers - } + numWorkers := max(runtime.NumCPU()*cpuMultiplier, minWorkers) if numWorkers > maxWorkers { numWorkers = maxWorkers } @@ -289,10 +286,7 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned * ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - concurrency := runtime.NumCPU() * 4 - if concurrency > 64 { - concurrency = 64 - } + concurrency := min(runtime.NumCPU()*4, 64) sem := make(chan struct{}, concurrency) var walk func(string) @@ -363,10 +357,9 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry { return nil } - lines := strings.Split(strings.TrimSpace(string(output)), "\n") var files []fileEntry - for _, line := range lines { + for line := range strings.Lines(strings.TrimSpace(string(output))) { if line == "" { continue } @@ -413,8 +406,8 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry { // isInFoldedDir checks if a path is inside a folded directory. func isInFoldedDir(path string) bool { - parts := strings.Split(path, string(os.PathSeparator)) - for _, part := range parts { + parts := strings.SplitSeq(path, string(os.PathSeparator)) + for part := range parts { if foldDirs[part] { return true } @@ -432,10 +425,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil var wg sync.WaitGroup // Limit concurrent subdirectory scans. - maxConcurrent := runtime.NumCPU() * 2 - if maxConcurrent > maxDirWorkers { - maxConcurrent = maxDirWorkers - } + maxConcurrent := min(runtime.NumCPU()*2, maxDirWorkers) sem := make(chan struct{}, maxConcurrent) for _, child := range children { diff --git a/cmd/analyze/view.go b/cmd/analyze/view.go index a434c71..6cdf400 100644 --- a/cmd/analyze/view.go +++ b/cmd/analyze/view.go @@ -100,14 +100,8 @@ func (m model) View() string { fmt.Fprintln(&b, " No large files found (>=100MB)") } else { viewport := calculateViewport(m.height, true) - start := m.largeOffset - if start < 0 { - start = 0 - } - end := start + viewport - if end > len(m.largeFiles) { - end = len(m.largeFiles) - } + start := max(m.largeOffset, 0) + end := min(start+viewport, len(m.largeFiles)) maxLargeSize := int64(1) for _, file := range m.largeFiles { if file.Size > maxLargeSize { @@ -163,10 +157,7 @@ func (m model) View() string { for idx, entry := range m.entries { icon := "📁" sizeVal := entry.Size - barValue := sizeVal - if barValue < 0 { - barValue = 0 - } + barValue := max(sizeVal, 0) var percent float64 if totalSize > 0 && sizeVal >= 0 { percent = float64(sizeVal) / float64(totalSize) * 100 @@ -243,14 +234,8 @@ func (m model) View() string { viewport := calculateViewport(m.height, false) nameWidth := calculateNameWidth(m.width) - start := m.offset - if start < 0 { - start = 0 - } - end := start + viewport - if end > len(m.entries) { - end = len(m.entries) - } + start := max(m.offset, 0) + end := min(start+viewport, len(m.entries)) for idx := start; idx < end; idx++ { entry := m.entries[idx] diff --git a/cmd/status/main.go b/cmd/status/main.go index 4e152ee..b8f7aeb 100644 --- a/cmd/status/main.go +++ b/cmd/status/main.go @@ -1,8 +1,11 @@ +// Package main provides the mo status command for real-time system monitoring. package main import ( "fmt" "os" + "path/filepath" + "strings" "time" tea "github.com/charmbracelet/bubbletea" @@ -34,11 +37,53 @@ type model struct { lastUpdated time.Time collecting bool animFrame int + catHidden bool // true = hidden, false = visible +} + +// getConfigPath returns the path to the status preferences file. +func getConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "mole", "status_prefs") +} + +// loadCatHidden loads the cat hidden preference from config file. +func loadCatHidden() bool { + path := getConfigPath() + if path == "" { + return false + } + data, err := os.ReadFile(path) + if err != nil { + return false + } + return strings.TrimSpace(string(data)) == "cat_hidden=true" +} + +// saveCatHidden saves the cat hidden preference to config file. +func saveCatHidden(hidden bool) { + path := getConfigPath() + if path == "" { + return + } + // Ensure directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return + } + value := "cat_hidden=false" + if hidden { + value = "cat_hidden=true" + } + _ = os.WriteFile(path, []byte(value+"\n"), 0644) } func newModel() model { return model{ collector: NewCollector(), + catHidden: loadCatHidden(), } } @@ -52,6 +97,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "esc", "ctrl+c": return m, tea.Quit + case "k": + // Toggle cat visibility and persist preference + m.catHidden = !m.catHidden + saveCatHidden(m.catHidden) + return m, nil } case tea.WindowSizeMsg: m.width = msg.Width @@ -89,7 +139,7 @@ func (m model) View() string { return "Loading..." } - header := renderHeader(m.metrics, m.errMessage, m.animFrame, m.width) + header := renderHeader(m.metrics, m.errMessage, m.animFrame, m.width, m.catHidden) cardWidth := 0 if m.width > 80 { cardWidth = maxInt(24, m.width/2-4) @@ -104,10 +154,20 @@ func (m model) View() string { } rendered = append(rendered, renderCard(c, cardWidth, 0)) } - return header + "\n" + lipgloss.JoinVertical(lipgloss.Left, rendered...) + result := header + "\n" + lipgloss.JoinVertical(lipgloss.Left, rendered...) + // Add extra newline if cat is hidden for better spacing + if m.catHidden { + result = header + "\n\n" + lipgloss.JoinVertical(lipgloss.Left, rendered...) + } + return result } - return header + "\n" + renderTwoColumns(cards, m.width) + twoCol := renderTwoColumns(cards, m.width) + // Add extra newline if cat is hidden for better spacing + if m.catHidden { + return header + "\n\n" + twoCol + } + return header + "\n" + twoCol } func (m model) collectCmd() tea.Cmd { @@ -127,16 +187,13 @@ func animTick() tea.Cmd { func animTickWithSpeed(cpuUsage float64) tea.Cmd { // Higher CPU = faster animation. - interval := 300 - int(cpuUsage*2.5) - if interval < 50 { - interval = 50 - } + interval := max(300-int(cpuUsage*2.5), 50) return tea.Tick(time.Duration(interval)*time.Millisecond, func(time.Time) tea.Msg { return animTickMsg{} }) } func main() { p := tea.NewProgram(newModel(), tea.WithAltScreen()) - if err := p.Start(); err != nil { + if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "system status error: %v\n", err) os.Exit(1) } diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go index bb5df62..2b24bba 100644 --- a/cmd/status/metrics.go +++ b/cmd/status/metrics.go @@ -286,9 +286,8 @@ func commandExists(name string) bool { return false } defer func() { - if r := recover(); r != nil { - // Treat LookPath panics as "missing". - } + // Treat LookPath panics as "missing". + _ = recover() }() _, err := exec.LookPath(name) return err == nil diff --git a/cmd/status/metrics_battery.go b/cmd/status/metrics_battery.go index ecdb463..57f1f8b 100644 --- a/cmd/status/metrics_battery.go +++ b/cmd/status/metrics_battery.go @@ -68,11 +68,10 @@ func collectBatteries() (batts []BatteryStatus, err error) { } func parsePMSet(raw string, health string, cycles int, capacity int) []BatteryStatus { - lines := strings.Split(raw, "\n") var out []BatteryStatus var timeLeft string - for _, line := range lines { + for line := range strings.Lines(raw) { // Time remaining. if strings.Contains(line, "remaining") { parts := strings.Fields(line) @@ -128,8 +127,7 @@ func getCachedPowerData() (health string, cycles int, capacity int) { return "", 0, 0 } - lines := strings.Split(out, "\n") - for _, line := range lines { + for line := range strings.Lines(out) { lower := strings.ToLower(line) if strings.Contains(lower, "cycle count") { if _, after, found := strings.Cut(line, ":"); found { @@ -183,8 +181,7 @@ func collectThermal() ThermalStatus { // Fan info from cached system_profiler. out := getSystemPowerOutput() if out != "" { - lines := strings.Split(out, "\n") - for _, line := range lines { + for line := range strings.Lines(out) { lower := strings.ToLower(line) if strings.Contains(lower, "fan") && strings.Contains(lower, "speed") { if _, after, found := strings.Cut(line, ":"); found { @@ -200,8 +197,7 @@ func collectThermal() ThermalStatus { ctxPower, cancelPower := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancelPower() if out, err := runCmd(ctxPower, "ioreg", "-rn", "AppleSmartBattery"); err == nil { - lines := strings.Split(out, "\n") - for _, line := range lines { + for line := range strings.Lines(out) { line = strings.TrimSpace(line) // Battery temperature ("Temperature" = 3055). @@ -242,8 +238,9 @@ func collectThermal() ThermalStatus { valStr, _, _ = strings.Cut(valStr, ",") valStr, _, _ = strings.Cut(valStr, "}") valStr = strings.TrimSpace(valStr) - if powerMW, err := strconv.ParseFloat(valStr, 64); err == nil { - thermal.BatteryPower = powerMW / 1000.0 + // Parse as int64 first to handle negative values (charging) + if powerMW, err := strconv.ParseInt(valStr, 10, 64); err == nil { + thermal.BatteryPower = float64(powerMW) / 1000.0 } } } diff --git a/cmd/status/metrics_bluetooth.go b/cmd/status/metrics_bluetooth.go index f0c35a7..740c10c 100644 --- a/cmd/status/metrics_bluetooth.go +++ b/cmd/status/metrics_bluetooth.go @@ -68,13 +68,12 @@ func readBluetoothCTLDevices() ([]BluetoothDevice, error) { } func parseSPBluetooth(raw string) []BluetoothDevice { - lines := strings.Split(raw, "\n") var devices []BluetoothDevice var currentName string var connected bool var battery string - for _, line := range lines { + for line := range strings.Lines(raw) { trim := strings.TrimSpace(line) if len(trim) == 0 { continue @@ -112,10 +111,9 @@ func parseSPBluetooth(raw string) []BluetoothDevice { } func parseBluetoothctl(raw string) []BluetoothDevice { - lines := strings.Split(raw, "\n") var devices []BluetoothDevice current := BluetoothDevice{} - for _, line := range lines { + for line := range strings.Lines(raw) { trim := strings.TrimSpace(line) if strings.HasPrefix(trim, "Device ") { if current.Name != "" { @@ -123,8 +121,8 @@ func parseBluetoothctl(raw string) []BluetoothDevice { } current = BluetoothDevice{Name: strings.TrimPrefix(trim, "Device "), Connected: false} } - if strings.HasPrefix(trim, "Name:") { - current.Name = strings.TrimSpace(strings.TrimPrefix(trim, "Name:")) + if after, ok := strings.CutPrefix(trim, "Name:"); ok { + current.Name = strings.TrimSpace(after) } if strings.HasPrefix(trim, "Connected:") { current.Connected = strings.Contains(trim, "yes") diff --git a/cmd/status/metrics_cpu.go b/cmd/status/metrics_cpu.go index 59be505..29c7690 100644 --- a/cmd/status/metrics_cpu.go +++ b/cmd/status/metrics_cpu.go @@ -32,7 +32,7 @@ func collectCPU() (CPUStatus, error) { } // Two-call pattern for more reliable CPU usage. - cpu.Percent(0, true) + warmUpCPU() time.Sleep(cpuSampleInterval) percents, err := cpu.Percent(0, true) var totalPercent float64 @@ -119,7 +119,10 @@ func getCoreTopology() (pCores, eCores int) { return 0, 0 } - lines := strings.Split(strings.TrimSpace(out), "\n") + var lines []string + for line := range strings.Lines(strings.TrimSpace(out)) { + lines = append(lines, line) + } if len(lines) < 4 { return 0, 0 } @@ -252,3 +255,7 @@ func fallbackCPUUtilization(logical int) (float64, []float64, error) { } return avg, perCore, nil } + +func warmUpCPU() { + cpu.Percent(0, true) //nolint:errcheck +} diff --git a/cmd/status/metrics_disk.go b/cmd/status/metrics_disk.go index 3863aa0..9586fae 100644 --- a/cmd/status/metrics_disk.go +++ b/cmd/status/metrics_disk.go @@ -156,7 +156,7 @@ func isExternalDisk(device string) (bool, error) { found bool external bool ) - for _, line := range strings.Split(out, "\n") { + for line := range strings.Lines(out) { trim := strings.TrimSpace(line) if strings.HasPrefix(trim, "Internal:") { found = true diff --git a/cmd/status/metrics_gpu.go b/cmd/status/metrics_gpu.go index d4775f2..bb60235 100644 --- a/cmd/status/metrics_gpu.go +++ b/cmd/status/metrics_gpu.go @@ -61,9 +61,8 @@ func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) { return nil, err } - lines := strings.Split(strings.TrimSpace(out), "\n") var gpus []GPUStatus - for _, line := range lines { + for line := range strings.Lines(strings.TrimSpace(out)) { fields := strings.Split(line, ",") if len(fields) < 4 { continue diff --git a/cmd/status/metrics_hardware.go b/cmd/status/metrics_hardware.go index 731d6a7..54ff7ba 100644 --- a/cmd/status/metrics_hardware.go +++ b/cmd/status/metrics_hardware.go @@ -28,8 +28,7 @@ func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo { out, err := runCmd(ctx, "system_profiler", "SPHardwareDataType") if err == nil { - lines := strings.Split(out, "\n") - for _, line := range lines { + for line := range strings.Lines(out) { lower := strings.ToLower(strings.TrimSpace(line)) // Prefer "Model Name" over "Model Identifier". if strings.Contains(lower, "model name:") { @@ -85,10 +84,9 @@ func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo { // parseRefreshRate extracts the highest refresh rate from system_profiler display output. func parseRefreshRate(output string) string { - lines := strings.Split(output, "\n") maxHz := 0 - for _, line := range lines { + for line := range strings.Lines(output) { lower := strings.ToLower(line) // Look for patterns like "@ 60Hz", "@ 60.00Hz", or "Refresh Rate: 120 Hz". if strings.Contains(lower, "hz") { @@ -100,8 +98,7 @@ func parseRefreshRate(output string) string { } continue } - if strings.HasSuffix(field, "hz") { - numStr := strings.TrimSuffix(field, "hz") + if numStr, ok := strings.CutSuffix(field, "hz"); ok { if numStr == "" && i > 0 { numStr = fields[i-1] } @@ -133,6 +130,8 @@ func parseInt(s string) int { return 0 } var num int - fmt.Sscanf(cleaned, "%d", &num) + if _, err := fmt.Sscanf(cleaned, "%d", &num); err != nil { + return 0 + } return num } diff --git a/cmd/status/metrics_health.go b/cmd/status/metrics_health.go index 3a556f2..0e34828 100644 --- a/cmd/status/metrics_health.go +++ b/cmd/status/metrics_health.go @@ -70,10 +70,12 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d } // Memory pressure penalty. - if mem.Pressure == "warn" { + // Memory pressure penalty. + switch mem.Pressure { + case "warn": score -= memPressureWarnPenalty issues = append(issues, "Memory Pressure") - } else if mem.Pressure == "critical" { + case "critical": score -= memPressureCritPenalty issues = append(issues, "Critical Memory") } @@ -131,16 +133,17 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d } // Build message. - msg := "Excellent" - if score >= 90 { + var msg string + switch { + case score >= 90: msg = "Excellent" - } else if score >= 75 { + case score >= 75: msg = "Good" - } else if score >= 60 { + case score >= 60: msg = "Fair" - } else if score >= 40 { + case score >= 40: msg = "Poor" - } else { + default: msg = "Critical" } diff --git a/cmd/status/metrics_memory.go b/cmd/status/metrics_memory.go index 5851dd5..6cdd021 100644 --- a/cmd/status/metrics_memory.go +++ b/cmd/status/metrics_memory.go @@ -46,22 +46,22 @@ func getFileBackedMemory() uint64 { // Parse page size from first line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)" var pageSize uint64 = 4096 // Default - lines := strings.Split(out, "\n") - if len(lines) > 0 { - firstLine := lines[0] - if strings.Contains(firstLine, "page size of") { - if _, after, found := strings.Cut(firstLine, "page size of "); found { - if before, _, found := strings.Cut(after, " bytes"); found { - if size, err := strconv.ParseUint(strings.TrimSpace(before), 10, 64); err == nil { - pageSize = size + firstLine := true + for line := range strings.Lines(out) { + if firstLine { + firstLine = false + if strings.Contains(line, "page size of") { + if _, after, found := strings.Cut(line, "page size of "); found { + if before, _, found := strings.Cut(after, " bytes"); found { + if size, err := strconv.ParseUint(strings.TrimSpace(before), 10, 64); err == nil { + pageSize = size + } } } } } - } - // Parse "File-backed pages: 388975." - for _, line := range lines { + // Parse "File-backed pages: 388975." if strings.Contains(line, "File-backed pages:") { if _, after, found := strings.Cut(line, ":"); found { numStr := strings.TrimSpace(after) diff --git a/cmd/status/metrics_process.go b/cmd/status/metrics_process.go index 5c497a4..b11f25c 100644 --- a/cmd/status/metrics_process.go +++ b/cmd/status/metrics_process.go @@ -21,15 +21,17 @@ func collectTopProcesses() []ProcessInfo { return nil } - lines := strings.Split(strings.TrimSpace(out), "\n") var procs []ProcessInfo - for i, line := range lines { + i := 0 + for line := range strings.Lines(strings.TrimSpace(out)) { if i == 0 { + i++ continue } if i > 5 { break } + i++ fields := strings.Fields(line) if len(fields) < 3 { continue diff --git a/cmd/status/view.go b/cmd/status/view.go index a5ef8ea..7ef56cd 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -32,7 +32,7 @@ const ( iconProcs = "❊" ) -// Mole body frames. +// Mole body frames (facing right). var moleBody = [][]string{ { ` /\_/\`, @@ -60,26 +60,60 @@ var moleBody = [][]string{ }, } +// Mirror mole body frames (facing left). +var moleBodyMirror = [][]string{ + { + ` /\_/\`, + ` / o o \___`, + ` \ =-= ___\`, + ` (m-m-(____/`, + }, + { + ` /\_/\`, + ` / o o \___`, + ` \ =-= ___\`, + ` (__mm(____/`, + }, + { + ` /\_/\`, + ` / · · \___`, + ` \ =-= ___\`, + ` (m__m-(___/`, + }, + { + ` /\_/\`, + ` / o o \___`, + ` \ =-= ___\`, + ` (-mm-(____/`, + }, +} + // getMoleFrame renders the animated mole. func getMoleFrame(animFrame int, termWidth int) string { - bodyIdx := animFrame % len(moleBody) - body := moleBody[bodyIdx] - moleWidth := 15 - maxPos := termWidth - moleWidth - if maxPos < 0 { - maxPos = 0 - } + maxPos := max(termWidth-moleWidth, 0) cycleLength := maxPos * 2 if cycleLength == 0 { cycleLength = 1 } pos := animFrame % cycleLength - if pos > maxPos { + movingLeft := pos > maxPos + if movingLeft { pos = cycleLength - pos } + // Use mirror frames when moving left + var frames [][]string + if movingLeft { + frames = moleBodyMirror + } else { + frames = moleBody + } + + bodyIdx := animFrame % len(frames) + body := frames[bodyIdx] + padding := strings.Repeat(" ", pos) var lines []string @@ -96,7 +130,7 @@ type cardData struct { lines []string } -func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int) string { +func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int, catHidden bool) string { title := titleStyle.Render("Mole Status") scoreStyle := getScoreStyle(m.HealthScore) @@ -134,24 +168,35 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int headerLine := title + " " + scoreText + " " + strings.Join(infoParts, " · ") - mole := getMoleFrame(animFrame, termWidth) + // Show cat unless hidden + var mole string + if !catHidden { + mole = getMoleFrame(animFrame, termWidth) + } if errMsg != "" { + if mole == "" { + return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", dangerStyle.Render("ERROR: "+errMsg), "") + } return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", mole, dangerStyle.Render("ERROR: "+errMsg), "") } + if mole == "" { + return headerLine + } return headerLine + "\n" + mole } func getScoreStyle(score int) lipgloss.Style { - if score >= 90 { + switch { + case score >= 90: return lipgloss.NewStyle().Foreground(lipgloss.Color("#87FF87")).Bold(true) - } else if score >= 75 { + case score >= 75: return lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")).Bold(true) - } else if score >= 60 { + case score >= 60: return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true) - } else if score >= 40 { + case score >= 40: return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true) - } else { + default: return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true) } } @@ -197,10 +242,7 @@ func renderCPUCard(cpu CPUStatus) cardData { } sort.Slice(cores, func(i, j int) bool { return cores[i].val > cores[j].val }) - maxCores := 3 - if len(cores) < maxCores { - maxCores = len(cores) - } + maxCores := min(len(cores), 3) for i := 0; i < maxCores; i++ { c := cores[i] lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", c.idx+1, progressBar(c.val), c.val)) @@ -219,28 +261,6 @@ func renderCPUCard(cpu CPUStatus) cardData { return cardData{icon: iconCPU, title: "CPU", lines: lines} } -func renderGPUCard(gpus []GPUStatus) cardData { - var lines []string - if len(gpus) == 0 { - lines = append(lines, subtleStyle.Render("No GPU detected")) - } else { - for _, g := range gpus { - if g.Usage >= 0 { - lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(g.Usage), g.Usage)) - } - coreInfo := "" - if g.CoreCount > 0 { - coreInfo = fmt.Sprintf(" (%d cores)", g.CoreCount) - } - lines = append(lines, g.Name+coreInfo) - if g.Usage < 0 { - lines = append(lines, subtleStyle.Render("Run with sudo for usage metrics")) - } - } - } - return cardData{icon: iconGPU, title: "GPU", lines: lines} -} - func renderMemoryCard(mem MemoryStatus) cardData { // Check if swap is being used (or at least allocated). hasSwap := mem.SwapTotal > 0 || mem.SwapUsed > 0 @@ -289,9 +309,10 @@ func renderMemoryCard(mem MemoryStatus) cardData { if mem.Pressure != "" { pressureStyle := okStyle pressureText := "Status " + mem.Pressure - if mem.Pressure == "warn" { + switch mem.Pressure { + case "warn": pressureStyle = warnStyle - } else if mem.Pressure == "critical" { + case "critical": pressureStyle = dangerStyle } lines = append(lines, pressureStyle.Render(pressureText)) @@ -356,10 +377,7 @@ func formatDiskLine(label string, d DiskStatus) string { } func ioBar(rate float64) string { - filled := int(rate / 10.0) - if filled > 5 { - filled = 5 - } + filled := min(int(rate/10.0), 5) if filled < 0 { filled = 0 } @@ -391,10 +409,7 @@ func renderProcessCard(procs []ProcessInfo) cardData { } func miniBar(percent float64) string { - filled := int(percent / 20) - if filled > 5 { - filled = 5 - } + filled := min(int(percent/20), 5) if filled < 0 { filled = 0 } @@ -437,10 +452,7 @@ func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData { } func netBar(rate float64) string { - filled := int(rate / 2.0) - if filled > 5 { - filled = 5 - } + filled := min(int(rate/2.0), 5) if filled < 0 { filled = 0 } @@ -501,6 +513,7 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { statusText += fmt.Sprintf(" · %.0fW Adapter", thermal.AdapterPower) } } else if thermal.BatteryPower > 0 { + // Only show battery power when discharging (positive value) statusText += fmt.Sprintf(" · %.0fW", thermal.BatteryPower) } lines = append(lines, statusStyle.Render(statusText+statusIcon)) @@ -551,10 +564,7 @@ func renderSensorsCard(sensors []SensorReading) cardData { func renderCard(data cardData, width int, height int) string { titleText := data.icon + " " + data.title - lineLen := width - lipgloss.Width(titleText) - 2 - if lineLen < 4 { - lineLen = 4 - } + lineLen := max(width-lipgloss.Width(titleText)-2, 4) header := titleStyle.Render(titleText) + " " + lineStyle.Render(strings.Repeat("╌", lineLen)) content := header + "\n" + strings.Join(data.lines, "\n") @@ -576,7 +586,7 @@ func progressBar(percent float64) string { filled := int(percent / 100 * float64(total)) var builder strings.Builder - for i := 0; i < total; i++ { + for i := range total { if i < filled { builder.WriteString("█") } else { @@ -597,7 +607,7 @@ func batteryProgressBar(percent float64) string { filled := int(percent / 100 * float64(total)) var builder strings.Builder - for i := 0; i < total; i++ { + for i := range total { if i < filled { builder.WriteString("█") } else { @@ -698,11 +708,11 @@ func humanBytesCompact(v uint64) string { } } -func shorten(s string, max int) string { - if len(s) <= max { +func shorten(s string, maxLen int) string { + if len(s) <= maxLen { return s } - return s[:max-1] + "…" + return s[:maxLen-1] + "…" } func renderTwoColumns(cards []cardData, width int) string { diff --git a/install.sh b/install.sh index b2408f3..13cbf8f 100755 --- a/install.sh +++ b/install.sh @@ -100,7 +100,7 @@ resolve_source_dir() { local tmp tmp="$(mktemp -d)" - trap "stop_line_spinner 2>/dev/null; rm -rf '$tmp'" EXIT + trap 'stop_line_spinner 2>/dev/null; rm -rf "$tmp"' EXIT local branch="${MOLE_VERSION:-}" if [[ -z "$branch" ]]; then diff --git a/lib/core/base.sh b/lib/core/base.sh index ab39a66..5a455e9 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -70,6 +70,7 @@ declare -a DEFAULT_WHITELIST_PATTERNS=( "$HOME/Library/Caches/pypoetry/virtualenvs*" "$HOME/Library/Caches/JetBrains*" "$HOME/Library/Caches/com.jetbrains.toolbox*" + "$HOME/Library/Application Support/JetBrains*" "$HOME/Library/Caches/com.apple.finder" "$HOME/Library/Mobile Documents*" # System-critical caches that affect macOS functionality and stability diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index c7b5bf3..4fb03a7 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -46,7 +46,9 @@ validate_path_for_deletion() { fi # Check for path traversal attempts - if [[ "$path" =~ \.\. ]]; then + # Only reject .. when it appears as a complete path component (/../ or /.. or ../) + # This allows legitimate directory names containing .. (e.g., Firefox's "name..files") + if [[ "$path" =~ (^|/)\.\.(\/|$) ]]; then log_error "Path validation failed: path traversal not allowed: $path" return 1 fi @@ -254,12 +256,10 @@ safe_find_delete() { find_args+=("-mtime" "+$age_days") fi - # Iterate results to respect should_protect_path when available + # Iterate results to respect should_protect_path while IFS= read -r -d '' match; do - if command -v should_protect_path > /dev/null 2>&1; then - if should_protect_path "$match"; then - continue - fi + if should_protect_path "$match"; then + continue fi safe_remove "$match" true || true done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) @@ -298,12 +298,10 @@ safe_sudo_find_delete() { find_args+=("-mtime" "+$age_days") fi - # Iterate results to respect should_protect_path when available + # Iterate results to respect should_protect_path while IFS= read -r -d '' match; do - if command -v should_protect_path > /dev/null 2>&1; then - if should_protect_path "$match"; then - continue - fi + if should_protect_path "$match"; then + continue fi safe_sudo_remove "$match" || true done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) diff --git a/lib/manage/update.sh b/lib/manage/update.sh index 996a16e..b700f31 100644 --- a/lib/manage/update.sh +++ b/lib/manage/update.sh @@ -96,9 +96,8 @@ ask_for_updates() { fi echo "" - echo -e "${YELLOW}Tip:${NC} Homebrew: brew upgrade / brew upgrade --cask" - echo -e "${YELLOW}Tip:${NC} App Store: open App Store → Updates" - echo -e "${YELLOW}Tip:${NC} macOS: System Settings → General → Software Update" + echo -e "${YELLOW}💡 Run ${GREEN}brew upgrade${YELLOW} to update${NC}" + return 1 } diff --git a/lib/manage/whitelist.sh b/lib/manage/whitelist.sh index 75f0762..e648e9e 100755 --- a/lib/manage/whitelist.sh +++ b/lib/manage/whitelist.sh @@ -85,7 +85,8 @@ Xcode archives (built app packages)|$HOME/Library/Developer/Xcode/Archives/*|ide Xcode internal cache files|$HOME/Library/Caches/com.apple.dt.Xcode/*|ide_cache Xcode iOS device support symbols|$HOME/Library/Developer/Xcode/iOS DeviceSupport/*/Symbols/System/Library/Caches/*|ide_cache Maven local repository (Java dependencies)|$HOME/.m2/repository/*|ide_cache -JetBrains IDEs cache (IntelliJ, PyCharm, WebStorm)|$HOME/Library/Caches/JetBrains/*|ide_cache +JetBrains IDEs data (IntelliJ, PyCharm, WebStorm, GoLand)|$HOME/Library/Application Support/JetBrains/*|ide_cache +JetBrains IDEs cache|$HOME/Library/Caches/JetBrains/*|ide_cache Android Studio cache and indexes|$HOME/Library/Caches/Google/AndroidStudio*/*|ide_cache Android build cache|$HOME/.android/build-cache/*|ide_cache VS Code runtime cache|$HOME/Library/Application Support/Code/Cache/*|ide_cache diff --git a/mole b/mole index 748a40e..398bf72 100755 --- a/mole +++ b/mole @@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh" trap cleanup_temp_files EXIT INT TERM # Version and update helpers -VERSION="1.19.0" +VERSION="1.20.0" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { @@ -345,7 +345,7 @@ update_mole() { if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then local new_version - new_version=$("$mole_path" --version 2>/dev/null | awk 'NR==1 && NF {print $NF}' || echo "") + new_version=$("$mole_path" --version 2> /dev/null | awk 'NR==1 && NF {print $NF}' || echo "") printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-unknown})" else printf '\n' diff --git a/scripts/check.sh b/scripts/check.sh index d4fbc50..4249a82 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -74,8 +74,12 @@ if [[ "$MODE" == "format" ]]; then exit 1 fi - if command -v go > /dev/null 2>&1; then - echo -e "${YELLOW}Formatting Go code...${NC}" + if command -v goimports > /dev/null 2>&1; then + echo -e "${YELLOW}Formatting Go code (goimports)...${NC}" + goimports -w -local github.com/tw93/Mole ./cmd + echo -e "${GREEN}${ICON_SUCCESS} Go formatting complete${NC}\n" + elif command -v go > /dev/null 2>&1; then + echo -e "${YELLOW}Formatting Go code (gofmt)...${NC}" gofmt -w ./cmd echo -e "${GREEN}${ICON_SUCCESS} Go formatting complete${NC}\n" else @@ -95,14 +99,42 @@ if [[ "$MODE" != "check" ]]; then echo -e "${YELLOW}${ICON_WARNING} shfmt not installed, skipping${NC}\n" fi - if command -v go > /dev/null 2>&1; then - echo -e "${YELLOW}2. Formatting Go code...${NC}" + if command -v goimports > /dev/null 2>&1; then + echo -e "${YELLOW}2. Formatting Go code (goimports)...${NC}" + goimports -w -local github.com/tw93/Mole ./cmd + echo -e "${GREEN}${ICON_SUCCESS} Go formatting applied${NC}\n" + elif command -v go > /dev/null 2>&1; then + echo -e "${YELLOW}2. Formatting Go code (gofmt)...${NC}" gofmt -w ./cmd echo -e "${GREEN}${ICON_SUCCESS} Go formatting applied${NC}\n" fi fi -echo -e "${YELLOW}3. Running ShellCheck...${NC}" +echo -e "${YELLOW}3. Running Go linters...${NC}" +if command -v golangci-lint > /dev/null 2>&1; then + if ! golangci-lint config verify; then + echo -e "${RED}${ICON_ERROR} golangci-lint config invalid${NC}\n" + exit 1 + fi + if golangci-lint run ./cmd/...; then + echo -e "${GREEN}${ICON_SUCCESS} golangci-lint passed${NC}\n" + else + echo -e "${RED}${ICON_ERROR} golangci-lint failed${NC}\n" + exit 1 + fi +elif command -v go > /dev/null 2>&1; then + echo -e "${YELLOW}${ICON_WARNING} golangci-lint not installed, falling back to go vet${NC}" + if go vet ./cmd/...; then + echo -e "${GREEN}${ICON_SUCCESS} go vet passed${NC}\n" + else + echo -e "${RED}${ICON_ERROR} go vet failed${NC}\n" + exit 1 + fi +else + echo -e "${YELLOW}${ICON_WARNING} Go not installed, skipping Go checks${NC}\n" +fi + +echo -e "${YELLOW}4. Running ShellCheck...${NC}" if command -v shellcheck > /dev/null 2>&1; then if shellcheck mole bin/*.sh lib/*/*.sh scripts/*.sh; then echo -e "${GREEN}${ICON_SUCCESS} ShellCheck passed${NC}\n" @@ -114,7 +146,7 @@ else echo -e "${YELLOW}${ICON_WARNING} shellcheck not installed, skipping${NC}\n" fi -echo -e "${YELLOW}4. Running syntax check...${NC}" +echo -e "${YELLOW}5. Running syntax check...${NC}" if ! bash -n mole; then echo -e "${RED}${ICON_ERROR} Syntax check failed (mole)${NC}\n" exit 1 @@ -133,7 +165,7 @@ find lib -name "*.sh" | while read -r script; do done echo -e "${GREEN}${ICON_SUCCESS} Syntax check passed${NC}\n" -echo -e "${YELLOW}5. Checking optimizations...${NC}" +echo -e "${YELLOW}6. Checking optimizations...${NC}" OPTIMIZATION_SCORE=0 TOTAL_CHECKS=0 diff --git a/tests/clean_misc.bats b/tests/clean_misc.bats index 1e7054e..c5e0d3c 100644 --- a/tests/clean_misc.bats +++ b/tests/clean_misc.bats @@ -92,9 +92,14 @@ EOF @test "scan_external_volumes skips when no volumes" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail +export DRY_RUN="false" source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" run_with_timeout() { return 1; } +# Mock missing dependencies and UI to ensure test passes regardless of volumes +clean_ds_store_tree() { :; } +start_section_spinner() { :; } +stop_section_spinner() { :; } scan_external_volumes EOF diff --git a/tests/core_safe_functions.bats b/tests/core_safe_functions.bats index c6bcba6..a720787 100644 --- a/tests/core_safe_functions.bats +++ b/tests/core_safe_functions.bats @@ -43,6 +43,26 @@ teardown() { @test "validate_path_for_deletion rejects path traversal" { run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/tmp/../etc'" [ "$status" -eq 1 ] + + # Test other path traversal patterns + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/var/log/../../etc'" + [ "$status" -eq 1 ] + + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/..'" + [ "$status" -eq 1 ] +} + +@test "validate_path_for_deletion accepts Firefox-style ..files directories" { + # Firefox uses ..files suffix in IndexedDB directory names + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/2753419432nreetyfallipx..files'" + [ "$status" -eq 0 ] + + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/storage/default/https+++www.netflix.com/idb/name..files/data'" + [ "$status" -eq 0 ] + + # Directories with .. in the middle of names should be allowed + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/test..backup/file.txt'" + [ "$status" -eq 0 ] } @test "validate_path_for_deletion rejects system directories" {