mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 15:39:42 +00:00
Merge branch 'main' into next
This commit is contained in:
23
.github/workflows/check.yml
vendored
23
.github/workflows/check.yml
vendored
@@ -27,20 +27,25 @@ jobs:
|
|||||||
~/Library/Caches/Homebrew
|
~/Library/Caches/Homebrew
|
||||||
/usr/local/Cellar/shfmt
|
/usr/local/Cellar/shfmt
|
||||||
/usr/local/Cellar/shellcheck
|
/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: |
|
restore-keys: |
|
||||||
${{ runner.os }}-brew-quality-
|
${{ runner.os }}-brew-quality-v2-
|
||||||
|
|
||||||
- name: Install tools
|
- name: Install tools
|
||||||
run: brew install shfmt shellcheck
|
run: brew install shfmt shellcheck golangci-lint
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5
|
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24.6'
|
go-version: '1.24.6'
|
||||||
|
|
||||||
|
- name: Install goimports
|
||||||
|
run: go install golang.org/x/tools/cmd/goimports@latest
|
||||||
|
|
||||||
- name: Format all code
|
- name: Format all code
|
||||||
run: |
|
run: |
|
||||||
|
export PATH=$(go env GOPATH)/bin:$PATH
|
||||||
./scripts/check.sh --format
|
./scripts/check.sh --format
|
||||||
|
|
||||||
- name: Commit formatting changes
|
- name: Commit formatting changes
|
||||||
@@ -75,12 +80,18 @@ jobs:
|
|||||||
~/Library/Caches/Homebrew
|
~/Library/Caches/Homebrew
|
||||||
/usr/local/Cellar/shfmt
|
/usr/local/Cellar/shfmt
|
||||||
/usr/local/Cellar/shellcheck
|
/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: |
|
restore-keys: |
|
||||||
${{ runner.os }}-brew-quality-
|
${{ runner.os }}-brew-quality-v2-
|
||||||
|
|
||||||
- name: Install tools
|
- 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
|
- name: Run check script
|
||||||
run: ./scripts/check.sh --no-format
|
run: ./scripts/check.sh --no-format
|
||||||
|
|||||||
26
.github/workflows/update-contributors.yml
vendored
26
.github/workflows/update-contributors.yml
vendored
@@ -25,26 +25,38 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Generate contributors SVG
|
- name: Generate contributors SVG
|
||||||
uses: wow-actions/contributors-list@v1
|
uses: tw93/contributors-list@master
|
||||||
with:
|
with:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
svgPath: CONTRIBUTORS.svg
|
svgPath: CONTRIBUTORS.svg
|
||||||
svgWidth: 1210
|
svgWidth: 1000
|
||||||
avatarMargin: 12
|
avatarSize: 72
|
||||||
|
avatarMargin: 45
|
||||||
userNameHeight: 20
|
userNameHeight: 20
|
||||||
|
noFetch: false
|
||||||
noCommit: true
|
noCommit: true
|
||||||
|
truncate: 0
|
||||||
includeBots: false
|
includeBots: false
|
||||||
excludeUsers: "github-actions web-flow dependabot claude"
|
excludeUsers: "github-actions web-flow dependabot claude"
|
||||||
itemTemplate: |
|
itemTemplate: |
|
||||||
<g transform="translate({{ x }}, {{ y }})">
|
<g transform="translate({{ x }}, {{ y }})">
|
||||||
<a xlink:href="{{{ url }}}" class="contributor-link" target="_blank" rel="nofollow sponsored" title="{{{ name }}}" id="{{{ name }}}">
|
<defs>
|
||||||
<image width="{{ width }}" height="{{ height }}" xlink:href="{{{ avatar }}}" />
|
<clipPath id="cp-{{ login }}">
|
||||||
<text x="32" y="74" text-anchor="middle" alignment-baseline="middle" font-size="10">{{{ name }}}</text>
|
<circle cx="36" cy="36" r="36" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<a xlink:href="{{{ url }}}" href="{{{ url }}}" class="contributor-link" target="_blank" rel="nofollow sponsored" title="{{{ name }}}">
|
||||||
|
<image width="72" height="72" xlink:href="{{{ avatar }}}" href="{{{ avatar }}}" clip-path="url(#cp-{{ login }})" />
|
||||||
|
<text x="36" y="86" text-anchor="middle" alignment-baseline="middle" font-size="10" fill="#666" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif">{{{ name }}}</text>
|
||||||
</a>
|
</a>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
- name: Commit & Push
|
- name: Commit & Push
|
||||||
uses: stefanzweifel/git-auto-commit-action@v5
|
uses: stefanzweifel/git-auto-commit-action@v7
|
||||||
with:
|
with:
|
||||||
commit_message: "chore: update contributors [skip ci]"
|
commit_message: "chore: update contributors [skip ci]"
|
||||||
file_pattern: CONTRIBUTORS.svg
|
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'
|
||||||
|
|||||||
43
.golangci.yml
Normal file
43
.golangci.yml
Normal file
@@ -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
|
||||||
@@ -4,7 +4,10 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install development tools
|
# 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
|
## Development
|
||||||
|
|||||||
323
CONTRIBUTORS.svg
Normal file
323
CONTRIBUTORS.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 255 KiB |
68
README.md
68
README.md
@@ -13,12 +13,12 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://cdn.tw93.fun/img/mole.jpeg" alt="Mole - 95.50GB freed" width="800" />
|
<img src="https://cdn.tw93.fun/img/mole.jpeg" alt="Mole - 95.50GB freed" width="1000" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Features
|
## 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**
|
- **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**
|
- **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
|
- **Disk insights**: Visualizes usage, manages large files, **rebuilds caches**, and refreshes system services
|
||||||
@@ -26,16 +26,16 @@
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
**Install by Brew, recommended:**
|
**Install via Homebrew — recommended:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install mole
|
brew install mole
|
||||||
```
|
```
|
||||||
|
|
||||||
**or by Script, for older macOS or latest code:**
|
**Or via script:**
|
||||||
|
|
||||||
```bash
|
```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
|
curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -71,13 +71,11 @@ mo purge --paths # Configure project scan directories
|
|||||||
## Tips
|
## Tips
|
||||||
|
|
||||||
- **Terminal**: iTerm2 has known compatibility issues; we recommend Alacritty, kitty, WezTerm, Ghostty, or Warp.
|
- **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`.
|
- **Safety**: Built with strict protections. See [Security Audit](SECURITY_AUDIT.md). Preview changes with `mo clean --dry-run`.
|
||||||
- **Whitelist**: Manage protected paths with `mo clean --whitelist`.
|
- **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.
|
||||||
- **Touch ID**: Enable Touch ID for sudo commands by running `mo touchid`.
|
- **Navigation**: Supports arrow keys and Vim bindings (`h/j/k/l`).
|
||||||
- **Shell Completion**: Enable tab completion by running `mo completion` (auto-detect and install).
|
- **Status Shortcuts**: In `mo status`, press `k` to toggle cat visibility and save preference, `q` to quit.
|
||||||
- **Navigation**: Supports standard arrow keys and Vim bindings (`h/j/k/l`).
|
- **Configuration**: Run `mo touchid` for Touch ID sudo, `mo completion` for shell tab completion, `mo clean --whitelist` to manage protected paths.
|
||||||
- **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.
|
|
||||||
|
|
||||||
## Features in Detail
|
## Features in Detail
|
||||||
|
|
||||||
@@ -142,7 +140,7 @@ System: 5/32 GB RAM | 333/460 GB Disk (72%) | Uptime 6d
|
|||||||
System optimization completed
|
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
|
### Disk Space Analyzer
|
||||||
@@ -209,7 +207,7 @@ Select Categories to Clean - 18.5GB (8 selected)
|
|||||||
● backend-service 2.5GB | node_modules
|
● 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.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Custom Scan Paths</strong></summary>
|
<summary><strong>Custom Scan Paths</strong></summary>
|
||||||
@@ -222,7 +220,7 @@ Run `mo purge --paths` to configure which directories to scan, or edit `~/.confi
|
|||||||
~/Work/ClientB
|
~/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.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -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
|
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=<name>` 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=<name>` 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 Love
|
||||||
|
|
||||||
<p align="center">
|
Mole wouldn't be possible without these amazing contributors. They've built countless features that make Mole what it is today. Go follow them! ❤️
|
||||||
<img src="https://cdn.tw93.fun/pic/lovemole.jpeg" alt="Community feedback on Mole" width="800" />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
Users from around the world are loving Mole! Join the community and share your experience.
|
<a href="https://github.com/tw93/Mole/graphs/contributors">
|
||||||
|
<img src="./CONTRIBUTORS.svg?v=2" width="1000" />
|
||||||
## Developers
|
|
||||||
|
|
||||||
Mole's development can not be without these Hackers. They contributed a lot of capabilities for Mole. Also, welcome to follow them! ❤️
|
|
||||||
|
|
||||||
<a href="https://github.com/tw93/mole/graphs/contributors">
|
|
||||||
<img src="https://raw.githubusercontent.com/tw93/mole/main/CONTRIBUTORS.svg?sanitize=true" alt="Contributors" width="1000" />
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
Join thousands of users worldwide who trust Mole to keep their Macs clean and optimized.
|
||||||
|
|
||||||
|
<img src="https://cdn.tw93.fun/pic/lovemole.jpeg" alt="Community feedback on Mole" width="1000" />
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
<details>
|
- 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.
|
||||||
<summary><strong>Sponsorship and Community</strong></summary>
|
|
||||||
<br/>
|
|
||||||
<a href="https://miaoyan.app/cats.html?name=Mole"><img src="https://miaoyan.app/assets/sponsors.svg" width="1000px" /></a>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
- If Mole saved you space, consider starring the repo or sharing it with friends who need a cleaner Mac.
|
|
||||||
- Have ideas or fixes? Check our [Contributing Guide](CONTRIBUTING.md), then open an issue or PR to help shape Mole's future.
|
- 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 <a href="https://miaoyan.app/cats.html?name=Mole" target="_blank">this link</a> to keep our mascots purring.
|
- Love Mole? <a href="https://miaoyan.app/cats.html?name=Mole" target="_blank">Buy Tw93 an ice-cold Coke</a> to keep the project alive and kicking! 🥤
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Friends who bought me Coke</strong></summary>
|
||||||
|
<br/>
|
||||||
|
<a href="https://miaoyan.app/cats.html?name=Mole"><img src="https://miaoyan.app/assets/sponsors.svg" width="1000" /></a>
|
||||||
|
</details>
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License - feel free to enjoy and participate in open source.
|
MIT License — feel free to enjoy and participate in open source.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Security Audit & Compliance Report**
|
**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 Date | December 31, 2025 |
|
||||||
| Audit Conclusion | **PASSED** |
|
| Audit Conclusion | **PASSED** |
|
||||||
| Mole Version | V1.17.0 |
|
| Mole Version | V1.19.0 |
|
||||||
| Audited Branch | `main` (HEAD) |
|
| Audited Branch | `main` (HEAD) |
|
||||||
| Scope | Shell scripts, Go binaries, Configuration |
|
| Scope | Shell scripts, Go binaries, Configuration |
|
||||||
| Methodology | Static analysis, Threat modeling, Code review |
|
| 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
|
- Comprehensive protection for VPN, AI tools, and system components
|
||||||
- Atomic operations with crash recovery mechanisms
|
- Atomic operations with crash recovery mechanisms
|
||||||
- Full user control with dry-run and whitelist capabilities
|
- 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 |
|
| Standard | Implementation |
|
||||||
|----------|----------------|
|
|----------|----------------|
|
||||||
| OWASP Secure Coding | Input validation, least privilege, defense-in-depth |
|
| 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-78 (Command Injection) | Control character filtering |
|
||||||
| CWE-59 (Link Following) | Symlink detection before privileged operations |
|
| CWE-59 (Link Following) | Symlink detection before privileged operations |
|
||||||
| Apple File System Guidelines | Respects SIP, Read-Only Volumes, TCC |
|
| Apple File System Guidelines | Respects SIP, Read-Only Volumes, TCC |
|
||||||
|
|||||||
@@ -308,9 +308,8 @@ safe_clean() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${TRACK_SECTION:-0}" == "1" && "${SECTION_ACTIVITY:-0}" == "0" ]]; then
|
# Always stop spinner before outputting results
|
||||||
stop_section_spinner
|
stop_section_spinner
|
||||||
fi
|
|
||||||
|
|
||||||
local description
|
local description
|
||||||
local -a targets
|
local -a targets
|
||||||
|
|||||||
@@ -205,12 +205,16 @@ collect_installers() {
|
|||||||
start_inline_spinner "Scanning for installers..."
|
start_inline_spinner "Scanning for installers..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Start debug session
|
||||||
|
debug_operation_start "Collect Installers" "Scanning for redundant installer files"
|
||||||
|
|
||||||
# Scan all paths, deduplicate, and sort results
|
# Scan all paths, deduplicate, and sort results
|
||||||
local -a all_files=()
|
local -a all_files=()
|
||||||
|
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
[[ -z "$file" ]] && continue
|
[[ -z "$file" ]] && continue
|
||||||
all_files+=("$file")
|
all_files+=("$file")
|
||||||
|
debug_file_action "Found installer" "$file"
|
||||||
done < <(scan_all_installers | sort -u)
|
done < <(scan_all_installers | sort -u)
|
||||||
|
|
||||||
if [[ -t 1 ]]; then
|
if [[ -t 1 ]]; then
|
||||||
@@ -245,9 +249,20 @@ collect_installers() {
|
|||||||
local size_human
|
local size_human
|
||||||
size_human=$(bytes_to_human "$file_size")
|
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
|
# Format display with alignment
|
||||||
local display
|
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
|
# Store installer data in parallel arrays
|
||||||
INSTALLER_PATHS+=("$file")
|
INSTALLER_PATHS+=("$file")
|
||||||
|
|||||||
@@ -59,6 +59,24 @@ scan_applications() {
|
|||||||
local temp_file
|
local temp_file
|
||||||
temp_file=$(create_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
|
local current_epoch
|
||||||
current_epoch=$(get_epoch_seconds)
|
current_epoch=$(get_epoch_seconds)
|
||||||
|
|
||||||
@@ -228,7 +246,6 @@ scan_applications() {
|
|||||||
local progress_file="${temp_file}.progress"
|
local progress_file="${temp_file}.progress"
|
||||||
echo "0" > "$progress_file"
|
echo "0" > "$progress_file"
|
||||||
|
|
||||||
local spinner_pid=""
|
|
||||||
(
|
(
|
||||||
# shellcheck disable=SC2329 # Function invoked indirectly via trap
|
# shellcheck disable=SC2329 # Function invoked indirectly via trap
|
||||||
cleanup_spinner() { exit 0; }
|
cleanup_spinner() { exit 0; }
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func cloneDirEntries(entries []dirEntry) []dirEntry {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
copied := make([]dirEntry, len(entries))
|
copied := make([]dirEntry, len(entries))
|
||||||
copy(copied, entries)
|
copy(copied, entries) //nolint:all
|
||||||
return copied
|
return copied
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ func cloneFileEntries(files []fileEntry) []fileEntry {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
copied := make([]fileEntry, len(files))
|
copied := make([]fileEntry, len(files))
|
||||||
copy(copied, files)
|
copy(copied, files) //nolint:all
|
||||||
return copied
|
return copied
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ func loadCacheFromDisk(path string) (*cacheEntry, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close() //nolint:errcheck
|
||||||
|
|
||||||
var entry cacheEntry
|
var entry cacheEntry
|
||||||
decoder := gob.NewDecoder(file)
|
decoder := gob.NewDecoder(file)
|
||||||
@@ -258,7 +258,7 @@ func saveCacheToDisk(path string, result scanResult) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close() //nolint:errcheck
|
||||||
|
|
||||||
encoder := gob.NewEncoder(file)
|
encoder := gob.NewEncoder(file)
|
||||||
return encoder.Encode(entry)
|
return encoder.Encode(entry)
|
||||||
|
|||||||
@@ -93,15 +93,12 @@ func humanizeBytes(size int64) string {
|
|||||||
return fmt.Sprintf("%.1f %cB", value, "KMGTPE"[exp])
|
return fmt.Sprintf("%.1f %cB", value, "KMGTPE"[exp])
|
||||||
}
|
}
|
||||||
|
|
||||||
func coloredProgressBar(value, max int64, percent float64) string {
|
func coloredProgressBar(value, maxValue int64, percent float64) string {
|
||||||
if max <= 0 {
|
if maxValue <= 0 {
|
||||||
return colorGray + strings.Repeat("░", barWidth) + colorReset
|
return colorGray + strings.Repeat("░", barWidth) + colorReset
|
||||||
}
|
}
|
||||||
|
|
||||||
filled := int((value * int64(barWidth)) / max)
|
filled := min(int((value*int64(barWidth))/maxValue), barWidth)
|
||||||
if filled > barWidth {
|
|
||||||
filled = barWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
var barColor string
|
var barColor string
|
||||||
if percent >= 50 {
|
if percent >= 50 {
|
||||||
@@ -114,26 +111,27 @@ func coloredProgressBar(value, max int64, percent float64) string {
|
|||||||
barColor = colorGreen
|
barColor = colorGreen
|
||||||
}
|
}
|
||||||
|
|
||||||
bar := barColor
|
var bar strings.Builder
|
||||||
for i := 0; i < barWidth; i++ {
|
bar.WriteString(barColor)
|
||||||
|
for i := range barWidth {
|
||||||
if i < filled {
|
if i < filled {
|
||||||
if i < filled-1 {
|
if i < filled-1 {
|
||||||
bar += "█"
|
bar.WriteString("█")
|
||||||
} else {
|
} else {
|
||||||
remainder := (value * int64(barWidth)) % max
|
remainder := (value * int64(barWidth)) % maxValue
|
||||||
if remainder > max/2 {
|
if remainder > maxValue/2 {
|
||||||
bar += "█"
|
bar.WriteString("█")
|
||||||
} else if remainder > max/4 {
|
} else if remainder > maxValue/4 {
|
||||||
bar += "▓"
|
bar.WriteString("▓")
|
||||||
} else {
|
} else {
|
||||||
bar += "▒"
|
bar.WriteString("▒")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
bar += colorGray + "░" + barColor
|
bar.WriteString(colorGray + "░" + barColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return bar + colorReset
|
return bar.String() + colorReset
|
||||||
}
|
}
|
||||||
|
|
||||||
// runeWidth returns display width for wide characters and emoji.
|
// runeWidth returns display width for wide characters and emoji.
|
||||||
@@ -181,10 +179,6 @@ func calculateNameWidth(termWidth int) int {
|
|||||||
return available
|
return available
|
||||||
}
|
}
|
||||||
|
|
||||||
func trimName(name string) string {
|
|
||||||
return trimNameWithWidth(name, 45) // Default width for backward compatibility
|
|
||||||
}
|
|
||||||
|
|
||||||
func trimNameWithWidth(name string, maxWidth int) string {
|
func trimNameWithWidth(name string, maxWidth int) string {
|
||||||
const (
|
const (
|
||||||
ellipsis = "..."
|
ellipsis = "..."
|
||||||
|
|||||||
@@ -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) 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) 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))
|
*h = append(*h, x.(dirEntry))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *entryHeap) Pop() interface{} {
|
func (h *entryHeap) Pop() any {
|
||||||
old := *h
|
old := *h
|
||||||
n := len(old)
|
n := len(old)
|
||||||
x := old[n-1]
|
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) 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) 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))
|
*h = append(*h, x.(fileEntry))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *largeFileHeap) Pop() interface{} {
|
func (h *largeFileHeap) Pop() any {
|
||||||
old := *h
|
old := *h
|
||||||
n := len(old)
|
n := len(old)
|
||||||
x := old[n-1]
|
x := old[n-1]
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ func main() {
|
|||||||
go prefetchOverviewCache(prefetchCtx)
|
go prefetchOverviewCache(prefetchCtx)
|
||||||
|
|
||||||
p := tea.NewProgram(newModel(abs, isOverview), tea.WithAltScreen())
|
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)
|
fmt.Fprintf(os.Stderr, "analyzer error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -359,7 +359,7 @@ func (m model) scanCmd(path string) tea.Cmd {
|
|||||||
return scanResultMsg{result: result, err: nil}
|
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)
|
return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -997,10 +997,7 @@ func (m *model) clampEntrySelection() {
|
|||||||
m.selected = 0
|
m.selected = 0
|
||||||
}
|
}
|
||||||
viewport := calculateViewport(m.height, false)
|
viewport := calculateViewport(m.height, false)
|
||||||
maxOffset := len(m.entries) - viewport
|
maxOffset := max(len(m.entries)-viewport, 0)
|
||||||
if maxOffset < 0 {
|
|
||||||
maxOffset = 0
|
|
||||||
}
|
|
||||||
if m.offset > maxOffset {
|
if m.offset > maxOffset {
|
||||||
m.offset = maxOffset
|
m.offset = maxOffset
|
||||||
}
|
}
|
||||||
@@ -1025,10 +1022,7 @@ func (m *model) clampLargeSelection() {
|
|||||||
m.largeSelected = 0
|
m.largeSelected = 0
|
||||||
}
|
}
|
||||||
viewport := calculateViewport(m.height, true)
|
viewport := calculateViewport(m.height, true)
|
||||||
maxOffset := len(m.largeFiles) - viewport
|
maxOffset := max(len(m.largeFiles)-viewport, 0)
|
||||||
if maxOffset < 0 {
|
|
||||||
maxOffset = 0
|
|
||||||
}
|
|
||||||
if m.largeOffset > maxOffset {
|
if m.largeOffset > maxOffset {
|
||||||
m.largeOffset = maxOffset
|
m.largeOffset = maxOffset
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
|||||||
heap.Init(largeFilesHeap)
|
heap.Init(largeFilesHeap)
|
||||||
|
|
||||||
// Worker pool sized for I/O-bound scanning.
|
// Worker pool sized for I/O-bound scanning.
|
||||||
numWorkers := runtime.NumCPU() * cpuMultiplier
|
numWorkers := max(runtime.NumCPU()*cpuMultiplier, minWorkers)
|
||||||
if numWorkers < minWorkers {
|
|
||||||
numWorkers = minWorkers
|
|
||||||
}
|
|
||||||
if numWorkers > maxWorkers {
|
if numWorkers > maxWorkers {
|
||||||
numWorkers = maxWorkers
|
numWorkers = maxWorkers
|
||||||
}
|
}
|
||||||
@@ -289,10 +286,7 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
concurrency := runtime.NumCPU() * 4
|
concurrency := min(runtime.NumCPU()*4, 64)
|
||||||
if concurrency > 64 {
|
|
||||||
concurrency = 64
|
|
||||||
}
|
|
||||||
sem := make(chan struct{}, concurrency)
|
sem := make(chan struct{}, concurrency)
|
||||||
|
|
||||||
var walk func(string)
|
var walk func(string)
|
||||||
@@ -363,10 +357,9 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
|
||||||
var files []fileEntry
|
var files []fileEntry
|
||||||
|
|
||||||
for _, line := range lines {
|
for line := range strings.Lines(strings.TrimSpace(string(output))) {
|
||||||
if line == "" {
|
if line == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -413,8 +406,8 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
|
|||||||
|
|
||||||
// isInFoldedDir checks if a path is inside a folded directory.
|
// isInFoldedDir checks if a path is inside a folded directory.
|
||||||
func isInFoldedDir(path string) bool {
|
func isInFoldedDir(path string) bool {
|
||||||
parts := strings.Split(path, string(os.PathSeparator))
|
parts := strings.SplitSeq(path, string(os.PathSeparator))
|
||||||
for _, part := range parts {
|
for part := range parts {
|
||||||
if foldDirs[part] {
|
if foldDirs[part] {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -432,10 +425,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
// Limit concurrent subdirectory scans.
|
// Limit concurrent subdirectory scans.
|
||||||
maxConcurrent := runtime.NumCPU() * 2
|
maxConcurrent := min(runtime.NumCPU()*2, maxDirWorkers)
|
||||||
if maxConcurrent > maxDirWorkers {
|
|
||||||
maxConcurrent = maxDirWorkers
|
|
||||||
}
|
|
||||||
sem := make(chan struct{}, maxConcurrent)
|
sem := make(chan struct{}, maxConcurrent)
|
||||||
|
|
||||||
for _, child := range children {
|
for _, child := range children {
|
||||||
|
|||||||
@@ -100,14 +100,8 @@ func (m model) View() string {
|
|||||||
fmt.Fprintln(&b, " No large files found (>=100MB)")
|
fmt.Fprintln(&b, " No large files found (>=100MB)")
|
||||||
} else {
|
} else {
|
||||||
viewport := calculateViewport(m.height, true)
|
viewport := calculateViewport(m.height, true)
|
||||||
start := m.largeOffset
|
start := max(m.largeOffset, 0)
|
||||||
if start < 0 {
|
end := min(start+viewport, len(m.largeFiles))
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
end := start + viewport
|
|
||||||
if end > len(m.largeFiles) {
|
|
||||||
end = len(m.largeFiles)
|
|
||||||
}
|
|
||||||
maxLargeSize := int64(1)
|
maxLargeSize := int64(1)
|
||||||
for _, file := range m.largeFiles {
|
for _, file := range m.largeFiles {
|
||||||
if file.Size > maxLargeSize {
|
if file.Size > maxLargeSize {
|
||||||
@@ -163,10 +157,7 @@ func (m model) View() string {
|
|||||||
for idx, entry := range m.entries {
|
for idx, entry := range m.entries {
|
||||||
icon := "📁"
|
icon := "📁"
|
||||||
sizeVal := entry.Size
|
sizeVal := entry.Size
|
||||||
barValue := sizeVal
|
barValue := max(sizeVal, 0)
|
||||||
if barValue < 0 {
|
|
||||||
barValue = 0
|
|
||||||
}
|
|
||||||
var percent float64
|
var percent float64
|
||||||
if totalSize > 0 && sizeVal >= 0 {
|
if totalSize > 0 && sizeVal >= 0 {
|
||||||
percent = float64(sizeVal) / float64(totalSize) * 100
|
percent = float64(sizeVal) / float64(totalSize) * 100
|
||||||
@@ -243,14 +234,8 @@ func (m model) View() string {
|
|||||||
|
|
||||||
viewport := calculateViewport(m.height, false)
|
viewport := calculateViewport(m.height, false)
|
||||||
nameWidth := calculateNameWidth(m.width)
|
nameWidth := calculateNameWidth(m.width)
|
||||||
start := m.offset
|
start := max(m.offset, 0)
|
||||||
if start < 0 {
|
end := min(start+viewport, len(m.entries))
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
end := start + viewport
|
|
||||||
if end > len(m.entries) {
|
|
||||||
end = len(m.entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
for idx := start; idx < end; idx++ {
|
for idx := start; idx < end; idx++ {
|
||||||
entry := m.entries[idx]
|
entry := m.entries[idx]
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
// Package main provides the mo status command for real-time system monitoring.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -34,11 +37,53 @@ type model struct {
|
|||||||
lastUpdated time.Time
|
lastUpdated time.Time
|
||||||
collecting bool
|
collecting bool
|
||||||
animFrame int
|
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 {
|
func newModel() model {
|
||||||
return model{
|
return model{
|
||||||
collector: NewCollector(),
|
collector: NewCollector(),
|
||||||
|
catHidden: loadCatHidden(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +97,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "q", "esc", "ctrl+c":
|
case "q", "esc", "ctrl+c":
|
||||||
return m, tea.Quit
|
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:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
@@ -89,7 +139,7 @@ func (m model) View() string {
|
|||||||
return "Loading..."
|
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
|
cardWidth := 0
|
||||||
if m.width > 80 {
|
if m.width > 80 {
|
||||||
cardWidth = maxInt(24, m.width/2-4)
|
cardWidth = maxInt(24, m.width/2-4)
|
||||||
@@ -104,10 +154,20 @@ func (m model) View() string {
|
|||||||
}
|
}
|
||||||
rendered = append(rendered, renderCard(c, cardWidth, 0))
|
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 {
|
func (m model) collectCmd() tea.Cmd {
|
||||||
@@ -127,16 +187,13 @@ func animTick() tea.Cmd {
|
|||||||
|
|
||||||
func animTickWithSpeed(cpuUsage float64) tea.Cmd {
|
func animTickWithSpeed(cpuUsage float64) tea.Cmd {
|
||||||
// Higher CPU = faster animation.
|
// Higher CPU = faster animation.
|
||||||
interval := 300 - int(cpuUsage*2.5)
|
interval := max(300-int(cpuUsage*2.5), 50)
|
||||||
if interval < 50 {
|
|
||||||
interval = 50
|
|
||||||
}
|
|
||||||
return tea.Tick(time.Duration(interval)*time.Millisecond, func(time.Time) tea.Msg { return animTickMsg{} })
|
return tea.Tick(time.Duration(interval)*time.Millisecond, func(time.Time) tea.Msg { return animTickMsg{} })
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
p := tea.NewProgram(newModel(), tea.WithAltScreen())
|
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)
|
fmt.Fprintf(os.Stderr, "system status error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,9 +286,8 @@ func commandExists(name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
|
||||||
// Treat LookPath panics as "missing".
|
// Treat LookPath panics as "missing".
|
||||||
}
|
_ = recover()
|
||||||
}()
|
}()
|
||||||
_, err := exec.LookPath(name)
|
_, err := exec.LookPath(name)
|
||||||
return err == nil
|
return err == nil
|
||||||
|
|||||||
@@ -68,11 +68,10 @@ func collectBatteries() (batts []BatteryStatus, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parsePMSet(raw string, health string, cycles int, capacity int) []BatteryStatus {
|
func parsePMSet(raw string, health string, cycles int, capacity int) []BatteryStatus {
|
||||||
lines := strings.Split(raw, "\n")
|
|
||||||
var out []BatteryStatus
|
var out []BatteryStatus
|
||||||
var timeLeft string
|
var timeLeft string
|
||||||
|
|
||||||
for _, line := range lines {
|
for line := range strings.Lines(raw) {
|
||||||
// Time remaining.
|
// Time remaining.
|
||||||
if strings.Contains(line, "remaining") {
|
if strings.Contains(line, "remaining") {
|
||||||
parts := strings.Fields(line)
|
parts := strings.Fields(line)
|
||||||
@@ -128,8 +127,7 @@ func getCachedPowerData() (health string, cycles int, capacity int) {
|
|||||||
return "", 0, 0
|
return "", 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.Split(out, "\n")
|
for line := range strings.Lines(out) {
|
||||||
for _, line := range lines {
|
|
||||||
lower := strings.ToLower(line)
|
lower := strings.ToLower(line)
|
||||||
if strings.Contains(lower, "cycle count") {
|
if strings.Contains(lower, "cycle count") {
|
||||||
if _, after, found := strings.Cut(line, ":"); found {
|
if _, after, found := strings.Cut(line, ":"); found {
|
||||||
@@ -183,8 +181,7 @@ func collectThermal() ThermalStatus {
|
|||||||
// Fan info from cached system_profiler.
|
// Fan info from cached system_profiler.
|
||||||
out := getSystemPowerOutput()
|
out := getSystemPowerOutput()
|
||||||
if out != "" {
|
if out != "" {
|
||||||
lines := strings.Split(out, "\n")
|
for line := range strings.Lines(out) {
|
||||||
for _, line := range lines {
|
|
||||||
lower := strings.ToLower(line)
|
lower := strings.ToLower(line)
|
||||||
if strings.Contains(lower, "fan") && strings.Contains(lower, "speed") {
|
if strings.Contains(lower, "fan") && strings.Contains(lower, "speed") {
|
||||||
if _, after, found := strings.Cut(line, ":"); found {
|
if _, after, found := strings.Cut(line, ":"); found {
|
||||||
@@ -200,8 +197,7 @@ func collectThermal() ThermalStatus {
|
|||||||
ctxPower, cancelPower := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
ctxPower, cancelPower := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||||
defer cancelPower()
|
defer cancelPower()
|
||||||
if out, err := runCmd(ctxPower, "ioreg", "-rn", "AppleSmartBattery"); err == nil {
|
if out, err := runCmd(ctxPower, "ioreg", "-rn", "AppleSmartBattery"); err == nil {
|
||||||
lines := strings.Split(out, "\n")
|
for line := range strings.Lines(out) {
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
|
|
||||||
// Battery temperature ("Temperature" = 3055).
|
// Battery temperature ("Temperature" = 3055).
|
||||||
@@ -242,8 +238,9 @@ func collectThermal() ThermalStatus {
|
|||||||
valStr, _, _ = strings.Cut(valStr, ",")
|
valStr, _, _ = strings.Cut(valStr, ",")
|
||||||
valStr, _, _ = strings.Cut(valStr, "}")
|
valStr, _, _ = strings.Cut(valStr, "}")
|
||||||
valStr = strings.TrimSpace(valStr)
|
valStr = strings.TrimSpace(valStr)
|
||||||
if powerMW, err := strconv.ParseFloat(valStr, 64); err == nil {
|
// Parse as int64 first to handle negative values (charging)
|
||||||
thermal.BatteryPower = powerMW / 1000.0
|
if powerMW, err := strconv.ParseInt(valStr, 10, 64); err == nil {
|
||||||
|
thermal.BatteryPower = float64(powerMW) / 1000.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,13 +68,12 @@ func readBluetoothCTLDevices() ([]BluetoothDevice, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseSPBluetooth(raw string) []BluetoothDevice {
|
func parseSPBluetooth(raw string) []BluetoothDevice {
|
||||||
lines := strings.Split(raw, "\n")
|
|
||||||
var devices []BluetoothDevice
|
var devices []BluetoothDevice
|
||||||
var currentName string
|
var currentName string
|
||||||
var connected bool
|
var connected bool
|
||||||
var battery string
|
var battery string
|
||||||
|
|
||||||
for _, line := range lines {
|
for line := range strings.Lines(raw) {
|
||||||
trim := strings.TrimSpace(line)
|
trim := strings.TrimSpace(line)
|
||||||
if len(trim) == 0 {
|
if len(trim) == 0 {
|
||||||
continue
|
continue
|
||||||
@@ -112,10 +111,9 @@ func parseSPBluetooth(raw string) []BluetoothDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseBluetoothctl(raw string) []BluetoothDevice {
|
func parseBluetoothctl(raw string) []BluetoothDevice {
|
||||||
lines := strings.Split(raw, "\n")
|
|
||||||
var devices []BluetoothDevice
|
var devices []BluetoothDevice
|
||||||
current := BluetoothDevice{}
|
current := BluetoothDevice{}
|
||||||
for _, line := range lines {
|
for line := range strings.Lines(raw) {
|
||||||
trim := strings.TrimSpace(line)
|
trim := strings.TrimSpace(line)
|
||||||
if strings.HasPrefix(trim, "Device ") {
|
if strings.HasPrefix(trim, "Device ") {
|
||||||
if current.Name != "" {
|
if current.Name != "" {
|
||||||
@@ -123,8 +121,8 @@ func parseBluetoothctl(raw string) []BluetoothDevice {
|
|||||||
}
|
}
|
||||||
current = BluetoothDevice{Name: strings.TrimPrefix(trim, "Device "), Connected: false}
|
current = BluetoothDevice{Name: strings.TrimPrefix(trim, "Device "), Connected: false}
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(trim, "Name:") {
|
if after, ok := strings.CutPrefix(trim, "Name:"); ok {
|
||||||
current.Name = strings.TrimSpace(strings.TrimPrefix(trim, "Name:"))
|
current.Name = strings.TrimSpace(after)
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(trim, "Connected:") {
|
if strings.HasPrefix(trim, "Connected:") {
|
||||||
current.Connected = strings.Contains(trim, "yes")
|
current.Connected = strings.Contains(trim, "yes")
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func collectCPU() (CPUStatus, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Two-call pattern for more reliable CPU usage.
|
// Two-call pattern for more reliable CPU usage.
|
||||||
cpu.Percent(0, true)
|
warmUpCPU()
|
||||||
time.Sleep(cpuSampleInterval)
|
time.Sleep(cpuSampleInterval)
|
||||||
percents, err := cpu.Percent(0, true)
|
percents, err := cpu.Percent(0, true)
|
||||||
var totalPercent float64
|
var totalPercent float64
|
||||||
@@ -119,7 +119,10 @@ func getCoreTopology() (pCores, eCores int) {
|
|||||||
return 0, 0
|
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 {
|
if len(lines) < 4 {
|
||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
@@ -252,3 +255,7 @@ func fallbackCPUUtilization(logical int) (float64, []float64, error) {
|
|||||||
}
|
}
|
||||||
return avg, perCore, nil
|
return avg, perCore, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func warmUpCPU() {
|
||||||
|
cpu.Percent(0, true) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ func isExternalDisk(device string) (bool, error) {
|
|||||||
found bool
|
found bool
|
||||||
external bool
|
external bool
|
||||||
)
|
)
|
||||||
for _, line := range strings.Split(out, "\n") {
|
for line := range strings.Lines(out) {
|
||||||
trim := strings.TrimSpace(line)
|
trim := strings.TrimSpace(line)
|
||||||
if strings.HasPrefix(trim, "Internal:") {
|
if strings.HasPrefix(trim, "Internal:") {
|
||||||
found = true
|
found = true
|
||||||
|
|||||||
@@ -61,9 +61,8 @@ func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
|
||||||
var gpus []GPUStatus
|
var gpus []GPUStatus
|
||||||
for _, line := range lines {
|
for line := range strings.Lines(strings.TrimSpace(out)) {
|
||||||
fields := strings.Split(line, ",")
|
fields := strings.Split(line, ",")
|
||||||
if len(fields) < 4 {
|
if len(fields) < 4 {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -28,8 +28,7 @@ func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo {
|
|||||||
|
|
||||||
out, err := runCmd(ctx, "system_profiler", "SPHardwareDataType")
|
out, err := runCmd(ctx, "system_profiler", "SPHardwareDataType")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
lines := strings.Split(out, "\n")
|
for line := range strings.Lines(out) {
|
||||||
for _, line := range lines {
|
|
||||||
lower := strings.ToLower(strings.TrimSpace(line))
|
lower := strings.ToLower(strings.TrimSpace(line))
|
||||||
// Prefer "Model Name" over "Model Identifier".
|
// Prefer "Model Name" over "Model Identifier".
|
||||||
if strings.Contains(lower, "model name:") {
|
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.
|
// parseRefreshRate extracts the highest refresh rate from system_profiler display output.
|
||||||
func parseRefreshRate(output string) string {
|
func parseRefreshRate(output string) string {
|
||||||
lines := strings.Split(output, "\n")
|
|
||||||
maxHz := 0
|
maxHz := 0
|
||||||
|
|
||||||
for _, line := range lines {
|
for line := range strings.Lines(output) {
|
||||||
lower := strings.ToLower(line)
|
lower := strings.ToLower(line)
|
||||||
// Look for patterns like "@ 60Hz", "@ 60.00Hz", or "Refresh Rate: 120 Hz".
|
// Look for patterns like "@ 60Hz", "@ 60.00Hz", or "Refresh Rate: 120 Hz".
|
||||||
if strings.Contains(lower, "hz") {
|
if strings.Contains(lower, "hz") {
|
||||||
@@ -100,8 +98,7 @@ func parseRefreshRate(output string) string {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.HasSuffix(field, "hz") {
|
if numStr, ok := strings.CutSuffix(field, "hz"); ok {
|
||||||
numStr := strings.TrimSuffix(field, "hz")
|
|
||||||
if numStr == "" && i > 0 {
|
if numStr == "" && i > 0 {
|
||||||
numStr = fields[i-1]
|
numStr = fields[i-1]
|
||||||
}
|
}
|
||||||
@@ -133,6 +130,8 @@ func parseInt(s string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
var num int
|
var num int
|
||||||
fmt.Sscanf(cleaned, "%d", &num)
|
if _, err := fmt.Sscanf(cleaned, "%d", &num); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
return num
|
return num
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,10 +70,12 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Memory pressure penalty.
|
// Memory pressure penalty.
|
||||||
if mem.Pressure == "warn" {
|
// Memory pressure penalty.
|
||||||
|
switch mem.Pressure {
|
||||||
|
case "warn":
|
||||||
score -= memPressureWarnPenalty
|
score -= memPressureWarnPenalty
|
||||||
issues = append(issues, "Memory Pressure")
|
issues = append(issues, "Memory Pressure")
|
||||||
} else if mem.Pressure == "critical" {
|
case "critical":
|
||||||
score -= memPressureCritPenalty
|
score -= memPressureCritPenalty
|
||||||
issues = append(issues, "Critical Memory")
|
issues = append(issues, "Critical Memory")
|
||||||
}
|
}
|
||||||
@@ -131,16 +133,17 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build message.
|
// Build message.
|
||||||
msg := "Excellent"
|
var msg string
|
||||||
if score >= 90 {
|
switch {
|
||||||
|
case score >= 90:
|
||||||
msg = "Excellent"
|
msg = "Excellent"
|
||||||
} else if score >= 75 {
|
case score >= 75:
|
||||||
msg = "Good"
|
msg = "Good"
|
||||||
} else if score >= 60 {
|
case score >= 60:
|
||||||
msg = "Fair"
|
msg = "Fair"
|
||||||
} else if score >= 40 {
|
case score >= 40:
|
||||||
msg = "Poor"
|
msg = "Poor"
|
||||||
} else {
|
default:
|
||||||
msg = "Critical"
|
msg = "Critical"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,11 +46,12 @@ func getFileBackedMemory() uint64 {
|
|||||||
|
|
||||||
// Parse page size from first line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)"
|
// Parse page size from first line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)"
|
||||||
var pageSize uint64 = 4096 // Default
|
var pageSize uint64 = 4096 // Default
|
||||||
lines := strings.Split(out, "\n")
|
firstLine := true
|
||||||
if len(lines) > 0 {
|
for line := range strings.Lines(out) {
|
||||||
firstLine := lines[0]
|
if firstLine {
|
||||||
if strings.Contains(firstLine, "page size of") {
|
firstLine = false
|
||||||
if _, after, found := strings.Cut(firstLine, "page size of "); found {
|
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 before, _, found := strings.Cut(after, " bytes"); found {
|
||||||
if size, err := strconv.ParseUint(strings.TrimSpace(before), 10, 64); err == nil {
|
if size, err := strconv.ParseUint(strings.TrimSpace(before), 10, 64); err == nil {
|
||||||
pageSize = size
|
pageSize = size
|
||||||
@@ -61,7 +62,6 @@ func getFileBackedMemory() uint64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse "File-backed pages: 388975."
|
// Parse "File-backed pages: 388975."
|
||||||
for _, line := range lines {
|
|
||||||
if strings.Contains(line, "File-backed pages:") {
|
if strings.Contains(line, "File-backed pages:") {
|
||||||
if _, after, found := strings.Cut(line, ":"); found {
|
if _, after, found := strings.Cut(line, ":"); found {
|
||||||
numStr := strings.TrimSpace(after)
|
numStr := strings.TrimSpace(after)
|
||||||
|
|||||||
@@ -21,15 +21,17 @@ func collectTopProcesses() []ProcessInfo {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
|
||||||
var procs []ProcessInfo
|
var procs []ProcessInfo
|
||||||
for i, line := range lines {
|
i := 0
|
||||||
|
for line := range strings.Lines(strings.TrimSpace(out)) {
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
|
i++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if i > 5 {
|
if i > 5 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
i++
|
||||||
fields := strings.Fields(line)
|
fields := strings.Fields(line)
|
||||||
if len(fields) < 3 {
|
if len(fields) < 3 {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const (
|
|||||||
iconProcs = "❊"
|
iconProcs = "❊"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mole body frames.
|
// Mole body frames (facing right).
|
||||||
var moleBody = [][]string{
|
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.
|
// getMoleFrame renders the animated mole.
|
||||||
func getMoleFrame(animFrame int, termWidth int) string {
|
func getMoleFrame(animFrame int, termWidth int) string {
|
||||||
bodyIdx := animFrame % len(moleBody)
|
|
||||||
body := moleBody[bodyIdx]
|
|
||||||
|
|
||||||
moleWidth := 15
|
moleWidth := 15
|
||||||
maxPos := termWidth - moleWidth
|
maxPos := max(termWidth-moleWidth, 0)
|
||||||
if maxPos < 0 {
|
|
||||||
maxPos = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
cycleLength := maxPos * 2
|
cycleLength := maxPos * 2
|
||||||
if cycleLength == 0 {
|
if cycleLength == 0 {
|
||||||
cycleLength = 1
|
cycleLength = 1
|
||||||
}
|
}
|
||||||
pos := animFrame % cycleLength
|
pos := animFrame % cycleLength
|
||||||
if pos > maxPos {
|
movingLeft := pos > maxPos
|
||||||
|
if movingLeft {
|
||||||
pos = cycleLength - pos
|
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)
|
padding := strings.Repeat(" ", pos)
|
||||||
var lines []string
|
var lines []string
|
||||||
|
|
||||||
@@ -96,7 +130,7 @@ type cardData struct {
|
|||||||
lines []string
|
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")
|
title := titleStyle.Render("Mole Status")
|
||||||
|
|
||||||
scoreStyle := getScoreStyle(m.HealthScore)
|
scoreStyle := getScoreStyle(m.HealthScore)
|
||||||
@@ -134,24 +168,35 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int
|
|||||||
|
|
||||||
headerLine := title + " " + scoreText + " " + strings.Join(infoParts, " · ")
|
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 errMsg != "" {
|
||||||
|
if mole == "" {
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", dangerStyle.Render("ERROR: "+errMsg), "")
|
||||||
|
}
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", mole, dangerStyle.Render("ERROR: "+errMsg), "")
|
return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", mole, dangerStyle.Render("ERROR: "+errMsg), "")
|
||||||
}
|
}
|
||||||
|
if mole == "" {
|
||||||
|
return headerLine
|
||||||
|
}
|
||||||
return headerLine + "\n" + mole
|
return headerLine + "\n" + mole
|
||||||
}
|
}
|
||||||
|
|
||||||
func getScoreStyle(score int) lipgloss.Style {
|
func getScoreStyle(score int) lipgloss.Style {
|
||||||
if score >= 90 {
|
switch {
|
||||||
|
case score >= 90:
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87FF87")).Bold(true)
|
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)
|
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)
|
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)
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true)
|
||||||
} else {
|
default:
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true)
|
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 })
|
sort.Slice(cores, func(i, j int) bool { return cores[i].val > cores[j].val })
|
||||||
|
|
||||||
maxCores := 3
|
maxCores := min(len(cores), 3)
|
||||||
if len(cores) < maxCores {
|
|
||||||
maxCores = len(cores)
|
|
||||||
}
|
|
||||||
for i := 0; i < maxCores; i++ {
|
for i := 0; i < maxCores; i++ {
|
||||||
c := cores[i]
|
c := cores[i]
|
||||||
lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", c.idx+1, progressBar(c.val), c.val))
|
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}
|
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 {
|
func renderMemoryCard(mem MemoryStatus) cardData {
|
||||||
// Check if swap is being used (or at least allocated).
|
// Check if swap is being used (or at least allocated).
|
||||||
hasSwap := mem.SwapTotal > 0 || mem.SwapUsed > 0
|
hasSwap := mem.SwapTotal > 0 || mem.SwapUsed > 0
|
||||||
@@ -289,9 +309,10 @@ func renderMemoryCard(mem MemoryStatus) cardData {
|
|||||||
if mem.Pressure != "" {
|
if mem.Pressure != "" {
|
||||||
pressureStyle := okStyle
|
pressureStyle := okStyle
|
||||||
pressureText := "Status " + mem.Pressure
|
pressureText := "Status " + mem.Pressure
|
||||||
if mem.Pressure == "warn" {
|
switch mem.Pressure {
|
||||||
|
case "warn":
|
||||||
pressureStyle = warnStyle
|
pressureStyle = warnStyle
|
||||||
} else if mem.Pressure == "critical" {
|
case "critical":
|
||||||
pressureStyle = dangerStyle
|
pressureStyle = dangerStyle
|
||||||
}
|
}
|
||||||
lines = append(lines, pressureStyle.Render(pressureText))
|
lines = append(lines, pressureStyle.Render(pressureText))
|
||||||
@@ -356,10 +377,7 @@ func formatDiskLine(label string, d DiskStatus) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ioBar(rate float64) string {
|
func ioBar(rate float64) string {
|
||||||
filled := int(rate / 10.0)
|
filled := min(int(rate/10.0), 5)
|
||||||
if filled > 5 {
|
|
||||||
filled = 5
|
|
||||||
}
|
|
||||||
if filled < 0 {
|
if filled < 0 {
|
||||||
filled = 0
|
filled = 0
|
||||||
}
|
}
|
||||||
@@ -391,10 +409,7 @@ func renderProcessCard(procs []ProcessInfo) cardData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func miniBar(percent float64) string {
|
func miniBar(percent float64) string {
|
||||||
filled := int(percent / 20)
|
filled := min(int(percent/20), 5)
|
||||||
if filled > 5 {
|
|
||||||
filled = 5
|
|
||||||
}
|
|
||||||
if filled < 0 {
|
if filled < 0 {
|
||||||
filled = 0
|
filled = 0
|
||||||
}
|
}
|
||||||
@@ -437,10 +452,7 @@ func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func netBar(rate float64) string {
|
func netBar(rate float64) string {
|
||||||
filled := int(rate / 2.0)
|
filled := min(int(rate/2.0), 5)
|
||||||
if filled > 5 {
|
|
||||||
filled = 5
|
|
||||||
}
|
|
||||||
if filled < 0 {
|
if filled < 0 {
|
||||||
filled = 0
|
filled = 0
|
||||||
}
|
}
|
||||||
@@ -501,6 +513,7 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
|
|||||||
statusText += fmt.Sprintf(" · %.0fW Adapter", thermal.AdapterPower)
|
statusText += fmt.Sprintf(" · %.0fW Adapter", thermal.AdapterPower)
|
||||||
}
|
}
|
||||||
} else if thermal.BatteryPower > 0 {
|
} else if thermal.BatteryPower > 0 {
|
||||||
|
// Only show battery power when discharging (positive value)
|
||||||
statusText += fmt.Sprintf(" · %.0fW", thermal.BatteryPower)
|
statusText += fmt.Sprintf(" · %.0fW", thermal.BatteryPower)
|
||||||
}
|
}
|
||||||
lines = append(lines, statusStyle.Render(statusText+statusIcon))
|
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 {
|
func renderCard(data cardData, width int, height int) string {
|
||||||
titleText := data.icon + " " + data.title
|
titleText := data.icon + " " + data.title
|
||||||
lineLen := width - lipgloss.Width(titleText) - 2
|
lineLen := max(width-lipgloss.Width(titleText)-2, 4)
|
||||||
if lineLen < 4 {
|
|
||||||
lineLen = 4
|
|
||||||
}
|
|
||||||
header := titleStyle.Render(titleText) + " " + lineStyle.Render(strings.Repeat("╌", lineLen))
|
header := titleStyle.Render(titleText) + " " + lineStyle.Render(strings.Repeat("╌", lineLen))
|
||||||
content := header + "\n" + strings.Join(data.lines, "\n")
|
content := header + "\n" + strings.Join(data.lines, "\n")
|
||||||
|
|
||||||
@@ -576,7 +586,7 @@ func progressBar(percent float64) string {
|
|||||||
filled := int(percent / 100 * float64(total))
|
filled := int(percent / 100 * float64(total))
|
||||||
|
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
for i := 0; i < total; i++ {
|
for i := range total {
|
||||||
if i < filled {
|
if i < filled {
|
||||||
builder.WriteString("█")
|
builder.WriteString("█")
|
||||||
} else {
|
} else {
|
||||||
@@ -597,7 +607,7 @@ func batteryProgressBar(percent float64) string {
|
|||||||
filled := int(percent / 100 * float64(total))
|
filled := int(percent / 100 * float64(total))
|
||||||
|
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
for i := 0; i < total; i++ {
|
for i := range total {
|
||||||
if i < filled {
|
if i < filled {
|
||||||
builder.WriteString("█")
|
builder.WriteString("█")
|
||||||
} else {
|
} else {
|
||||||
@@ -698,11 +708,11 @@ func humanBytesCompact(v uint64) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func shorten(s string, max int) string {
|
func shorten(s string, maxLen int) string {
|
||||||
if len(s) <= max {
|
if len(s) <= maxLen {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return s[:max-1] + "…"
|
return s[:maxLen-1] + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderTwoColumns(cards []cardData, width int) string {
|
func renderTwoColumns(cards []cardData, width int) string {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ resolve_source_dir() {
|
|||||||
|
|
||||||
local tmp
|
local tmp
|
||||||
tmp="$(mktemp -d)"
|
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:-}"
|
local branch="${MOLE_VERSION:-}"
|
||||||
if [[ -z "$branch" ]]; then
|
if [[ -z "$branch" ]]; then
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ declare -a DEFAULT_WHITELIST_PATTERNS=(
|
|||||||
"$HOME/Library/Caches/pypoetry/virtualenvs*"
|
"$HOME/Library/Caches/pypoetry/virtualenvs*"
|
||||||
"$HOME/Library/Caches/JetBrains*"
|
"$HOME/Library/Caches/JetBrains*"
|
||||||
"$HOME/Library/Caches/com.jetbrains.toolbox*"
|
"$HOME/Library/Caches/com.jetbrains.toolbox*"
|
||||||
|
"$HOME/Library/Application Support/JetBrains*"
|
||||||
"$HOME/Library/Caches/com.apple.finder"
|
"$HOME/Library/Caches/com.apple.finder"
|
||||||
"$HOME/Library/Mobile Documents*"
|
"$HOME/Library/Mobile Documents*"
|
||||||
# System-critical caches that affect macOS functionality and stability
|
# System-critical caches that affect macOS functionality and stability
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ validate_path_for_deletion() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Check for path traversal attempts
|
# 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"
|
log_error "Path validation failed: path traversal not allowed: $path"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
@@ -254,13 +256,11 @@ safe_find_delete() {
|
|||||||
find_args+=("-mtime" "+$age_days")
|
find_args+=("-mtime" "+$age_days")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Iterate results to respect should_protect_path when available
|
# Iterate results to respect should_protect_path
|
||||||
while IFS= read -r -d '' match; do
|
while IFS= read -r -d '' match; do
|
||||||
if command -v should_protect_path > /dev/null 2>&1; then
|
|
||||||
if should_protect_path "$match"; then
|
if should_protect_path "$match"; then
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
safe_remove "$match" true || true
|
safe_remove "$match" true || true
|
||||||
done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
|
done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
|
||||||
|
|
||||||
@@ -298,13 +298,11 @@ safe_sudo_find_delete() {
|
|||||||
find_args+=("-mtime" "+$age_days")
|
find_args+=("-mtime" "+$age_days")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Iterate results to respect should_protect_path when available
|
# Iterate results to respect should_protect_path
|
||||||
while IFS= read -r -d '' match; do
|
while IFS= read -r -d '' match; do
|
||||||
if command -v should_protect_path > /dev/null 2>&1; then
|
|
||||||
if should_protect_path "$match"; then
|
if should_protect_path "$match"; then
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
safe_sudo_remove "$match" || true
|
safe_sudo_remove "$match" || true
|
||||||
done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
|
done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
|
||||||
|
|
||||||
|
|||||||
@@ -96,9 +96,8 @@ ask_for_updates() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${YELLOW}Tip:${NC} Homebrew: brew upgrade / brew upgrade --cask"
|
echo -e "${YELLOW}💡 Run ${GREEN}brew upgrade${YELLOW} to update${NC}"
|
||||||
echo -e "${YELLOW}Tip:${NC} App Store: open App Store → Updates"
|
|
||||||
echo -e "${YELLOW}Tip:${NC} macOS: System Settings → General → Software Update"
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 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
|
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
|
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 Studio cache and indexes|$HOME/Library/Caches/Google/AndroidStudio*/*|ide_cache
|
||||||
Android build cache|$HOME/.android/build-cache/*|ide_cache
|
Android build cache|$HOME/.android/build-cache/*|ide_cache
|
||||||
VS Code runtime cache|$HOME/Library/Application Support/Code/Cache/*|ide_cache
|
VS Code runtime cache|$HOME/Library/Application Support/Code/Cache/*|ide_cache
|
||||||
|
|||||||
2
mole
2
mole
@@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh"
|
|||||||
trap cleanup_temp_files EXIT INT TERM
|
trap cleanup_temp_files EXIT INT TERM
|
||||||
|
|
||||||
# Version and update helpers
|
# Version and update helpers
|
||||||
VERSION="1.19.0"
|
VERSION="1.20.0"
|
||||||
MOLE_TAGLINE="Deep clean and optimize your Mac."
|
MOLE_TAGLINE="Deep clean and optimize your Mac."
|
||||||
|
|
||||||
is_touchid_configured() {
|
is_touchid_configured() {
|
||||||
|
|||||||
@@ -74,8 +74,12 @@ if [[ "$MODE" == "format" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v go > /dev/null 2>&1; then
|
if command -v goimports > /dev/null 2>&1; then
|
||||||
echo -e "${YELLOW}Formatting Go code...${NC}"
|
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
|
gofmt -w ./cmd
|
||||||
echo -e "${GREEN}${ICON_SUCCESS} Go formatting complete${NC}\n"
|
echo -e "${GREEN}${ICON_SUCCESS} Go formatting complete${NC}\n"
|
||||||
else
|
else
|
||||||
@@ -95,14 +99,42 @@ if [[ "$MODE" != "check" ]]; then
|
|||||||
echo -e "${YELLOW}${ICON_WARNING} shfmt not installed, skipping${NC}\n"
|
echo -e "${YELLOW}${ICON_WARNING} shfmt not installed, skipping${NC}\n"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v go > /dev/null 2>&1; then
|
if command -v goimports > /dev/null 2>&1; then
|
||||||
echo -e "${YELLOW}2. Formatting Go code...${NC}"
|
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
|
gofmt -w ./cmd
|
||||||
echo -e "${GREEN}${ICON_SUCCESS} Go formatting applied${NC}\n"
|
echo -e "${GREEN}${ICON_SUCCESS} Go formatting applied${NC}\n"
|
||||||
fi
|
fi
|
||||||
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 command -v shellcheck > /dev/null 2>&1; then
|
||||||
if shellcheck mole bin/*.sh lib/*/*.sh scripts/*.sh; then
|
if shellcheck mole bin/*.sh lib/*/*.sh scripts/*.sh; then
|
||||||
echo -e "${GREEN}${ICON_SUCCESS} ShellCheck passed${NC}\n"
|
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"
|
echo -e "${YELLOW}${ICON_WARNING} shellcheck not installed, skipping${NC}\n"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}4. Running syntax check...${NC}"
|
echo -e "${YELLOW}5. Running syntax check...${NC}"
|
||||||
if ! bash -n mole; then
|
if ! bash -n mole; then
|
||||||
echo -e "${RED}${ICON_ERROR} Syntax check failed (mole)${NC}\n"
|
echo -e "${RED}${ICON_ERROR} Syntax check failed (mole)${NC}\n"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -133,7 +165,7 @@ find lib -name "*.sh" | while read -r script; do
|
|||||||
done
|
done
|
||||||
echo -e "${GREEN}${ICON_SUCCESS} Syntax check passed${NC}\n"
|
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
|
OPTIMIZATION_SCORE=0
|
||||||
TOTAL_CHECKS=0
|
TOTAL_CHECKS=0
|
||||||
|
|
||||||
|
|||||||
@@ -92,9 +92,14 @@ EOF
|
|||||||
@test "scan_external_volumes skips when no volumes" {
|
@test "scan_external_volumes skips when no volumes" {
|
||||||
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
|
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
export DRY_RUN="false"
|
||||||
source "$PROJECT_ROOT/lib/core/common.sh"
|
source "$PROJECT_ROOT/lib/core/common.sh"
|
||||||
source "$PROJECT_ROOT/lib/clean/user.sh"
|
source "$PROJECT_ROOT/lib/clean/user.sh"
|
||||||
run_with_timeout() { return 1; }
|
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
|
scan_external_volumes
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,26 @@ teardown() {
|
|||||||
@test "validate_path_for_deletion rejects path traversal" {
|
@test "validate_path_for_deletion rejects path traversal" {
|
||||||
run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/tmp/../etc'"
|
run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/tmp/../etc'"
|
||||||
[ "$status" -eq 1 ]
|
[ "$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" {
|
@test "validate_path_for_deletion rejects system directories" {
|
||||||
|
|||||||
Reference in New Issue
Block a user