1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-16 00:21:11 +00:00

Merge main into dev (resolve conflict in .gitignore)

This commit is contained in:
Tw93
2026-01-02 19:13:03 +08:00
130 changed files with 11880 additions and 4965 deletions

View File

@@ -8,7 +8,7 @@ assignees: ''
## Describe the bug ## Describe the bug
A clear and concise description of what the bug is. A clear and concise description of what the bug is. We suggest using English for better global understanding.
## Steps to reproduce ## Steps to reproduce

View File

@@ -8,7 +8,7 @@ assignees: ''
## Feature description ## Feature description
A clear and concise description of the feature you'd like to see. A clear and concise description of the feature you'd like to see. We suggest using English for better global understanding.
## Use case ## Use case

View File

@@ -1 +0,0 @@
../AGENT.md

11
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -1,4 +1,4 @@
name: Quality name: Check
on: on:
push: push:
@@ -10,18 +10,18 @@ permissions:
jobs: jobs:
format: format:
name: Auto Format name: Format
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4
with: with:
ref: ${{ github.head_ref }} ref: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref) || github.ref }}
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache Homebrew - name: Cache Homebrew
uses: actions/cache@v4 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4
with: with:
path: | path: |
~/Library/Caches/Homebrew ~/Library/Caches/Homebrew
@@ -35,19 +35,16 @@ jobs:
run: brew install shfmt shellcheck run: brew install shfmt shellcheck
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5
with: with:
go-version: '1.24' go-version: '1.24.6'
- name: Format all code - name: Format all code
run: | run: |
echo "Formatting shell scripts..." ./scripts/check.sh --format
./scripts/format.sh
echo "Formatting Go code..."
gofmt -w ./cmd
echo "✓ All code formatted"
- name: Commit formatting changes - name: Commit formatting changes
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
run: | run: |
git config user.name "Tw93" git config user.name "Tw93"
git config user.email "tw93@qq.com" git config user.email "tw93@qq.com"
@@ -61,18 +58,18 @@ jobs:
fi fi
quality: quality:
name: Code Quality name: Check
runs-on: macos-latest runs-on: macos-latest
needs: format needs: format
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4
with: with:
ref: ${{ github.head_ref }} ref: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref) || github.ref }}
- name: Cache Homebrew - name: Cache Homebrew
uses: actions/cache@v4 uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4
with: with:
path: | path: |
~/Library/Caches/Homebrew ~/Library/Caches/Homebrew
@@ -85,22 +82,5 @@ jobs:
- name: Install tools - name: Install tools
run: brew install shfmt shellcheck run: brew install shfmt shellcheck
- name: ShellCheck - name: Run check script
run: | run: ./scripts/check.sh --no-format
echo "Running ShellCheck on all shell scripts..."
shellcheck mole
shellcheck bin/*.sh
find lib -name "*.sh" -exec shellcheck {} +
echo "✓ ShellCheck passed"
- name: Syntax check
run: |
echo "Checking Bash syntax..."
bash -n mole
for script in bin/*.sh; do
bash -n "$script"
done
find lib -name "*.sh" | while read -r script; do
bash -n "$script"
done
echo "✓ All scripts have valid syntax"

View File

@@ -9,73 +9,78 @@ permissions:
contents: write contents: write
jobs: jobs:
build-release: build:
runs-on: macos-latest name: Build
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: macos-latest
target: release-amd64
artifact_name: binaries-amd64
- os: macos-latest
target: release-arm64
artifact_name: binaries-arm64
steps: steps:
- name: Checkout source code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5
with: with:
go-version: "1.24.6" go-version: "1.24.6"
cache: true
- name: Build Universal Binary for disk analyzer - name: Build Binaries
run: ./scripts/build-analyze.sh
- name: Build Universal Binary for system status
run: ./scripts/build-status.sh
- name: Verify binary is valid
run: | run: |
if [[ ! -x bin/analyze-go ]]; then make ${{ matrix.target }}
echo "Error: bin/analyze-go is not executable" ls -l bin/
exit 1
fi
if [[ ! -x bin/status-go ]]; then
echo "Error: bin/status-go is not executable"
exit 1
fi
echo "Binary info:"
file bin/analyze-go
ls -lh bin/analyze-go
file bin/status-go
ls -lh bin/status-go
echo ""
echo "✓ Universal binary built successfully"
- name: Commit binaries for release - name: Package binaries for Homebrew
run: | run: |
# Configure Git cd bin
git config user.name "Tw93" # Package binaries into tar.gz for Homebrew resource
git config user.email "tw93@qq.com" if [[ "${{ matrix.target }}" == "release-arm64" ]]; then
tar -czf binaries-darwin-arm64.tar.gz analyze-darwin-arm64 status-darwin-arm64
# Save binaries to temp location ls -lh binaries-darwin-arm64.tar.gz
cp bin/analyze-go /tmp/analyze-go
cp bin/status-go /tmp/status-go
# Switch to main branch
git fetch origin main
git checkout main
git pull origin main
# Restore binaries
mv /tmp/analyze-go bin/analyze-go
mv /tmp/status-go bin/status-go
# Commit and Push
git add bin/analyze-go bin/status-go
if git diff --staged --quiet; then
echo "No changes to commit"
else else
git commit -m "chore: update binaries for ${GITHUB_REF#refs/tags/}" tar -czf binaries-darwin-amd64.tar.gz analyze-darwin-amd64 status-darwin-amd64
git push origin main ls -lh binaries-darwin-amd64.tar.gz
fi fi
- name: Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: ${{ matrix.artifact_name }}
path: bin/*-darwin-*
retention-days: 1
release:
name: Publish Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
path: bin
pattern: binaries-*
merge-multiple: true
- name: Display structure of downloaded files
run: ls -R bin/
- name: Create Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: bin/*
generate_release_notes: true
draft: false
prerelease: false
update-formula: update-formula:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-release needs: release
steps: steps:
- name: Extract version from tag - name: Extract version from tag
id: tag_version id: tag_version
@@ -86,8 +91,8 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Releasing version: $VERSION (tag: $TAG)" echo "Releasing version: $VERSION (tag: $TAG)"
- name: Update Homebrew formula - name: Update Homebrew formula (Personal Tap)
uses: mislav/bump-homebrew-formula-action@v3 uses: mislav/bump-homebrew-formula-action@56a283fa15557e9abaa4bdb63b8212abc68e655c # v3.6
with: with:
formula-name: mole formula-name: mole
formula-path: Formula/mole.rb formula-path: Formula/mole.rb
@@ -100,9 +105,25 @@ jobs:
env: env:
COMMITTER_TOKEN: ${{ secrets.PAT_TOKEN }} COMMITTER_TOKEN: ${{ secrets.PAT_TOKEN }}
- name: Verify formula update - name: Update Homebrew formula (Official Core)
uses: mislav/bump-homebrew-formula-action@56a283fa15557e9abaa4bdb63b8212abc68e655c # v3.6
with:
formula-name: mole
homebrew-tap: Homebrew/homebrew-core
tag-name: ${{ steps.tag_version.outputs.tag }}
commit-message: |
mole ${{ steps.tag_version.outputs.version }}
Automated release via GitHub Actions
env:
COMMITTER_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }}
continue-on-error: true
- name: Verify formula updates
if: success() if: success()
run: | run: |
echo "✓ Homebrew formula updated successfully" echo "✓ Homebrew formulae updated successfully"
echo " Version: ${{ steps.tag_version.outputs.version }}" echo " Version: ${{ steps.tag_version.outputs.version }}"
echo " Tag: ${{ steps.tag_version.outputs.tag }}" echo " Tag: ${{ steps.tag_version.outputs.tag }}"
echo " Personal tap: tw93/homebrew-tap"
echo " Official core: Homebrew/homebrew-core (PR created)"

85
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,85 @@
name: Test
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
jobs:
tests:
name: Test
runs-on: macos-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4
- name: Install tools
run: brew install bats-core shellcheck
- name: Set up Go
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v5
with:
go-version: "1.24.6"
- name: Run test script
env:
MOLE_PERF_BYTES_TO_HUMAN_LIMIT_MS: "6000"
MOLE_PERF_GET_FILE_SIZE_LIMIT_MS: "3000"
run: ./scripts/test.sh
compatibility:
name: macOS Compatibility
strategy:
matrix:
os: [macos-14, macos-15]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4
- name: Test on ${{ matrix.os }}
run: |
echo "Testing on ${{ matrix.os }}..."
bash -n mole
source lib/core/common.sh
echo "✓ Successfully loaded on ${{ matrix.os }}"
security:
name: Security Checks
runs-on: macos-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4
- name: Check for unsafe rm usage
run: |
echo "Checking for unsafe rm patterns..."
if grep -r "rm -rf" --include="*.sh" lib/ | grep -v "safe_remove\|validate_path\|# "; then
echo "✗ Unsafe rm -rf usage found"
exit 1
fi
echo "✓ No unsafe rm usage found"
- name: Verify app protection
run: |
echo "Verifying critical file protection..."
bash -c '
source lib/core/common.sh
if should_protect_from_uninstall "com.apple.Safari"; then
echo "✓ Safari is protected"
else
echo "✗ Safari protection failed"
exit 1
fi
'
- name: Check for secrets
run: |
echo "Checking for hardcoded secrets..."
matches=$(grep -r "password\|secret\|api_key" --include="*.sh" . \
| grep -v "# \|test" \
| grep -v -E "lib/core/sudo\.sh|lib/core/app_protection\.sh|lib/clean/user\.sh|lib/clean/brew\.sh|bin/optimize\.sh" || true)
if [[ -n "$matches" ]]; then
echo "$matches"
echo "✗ Potential secrets found"
exit 1
fi
echo "✓ No secrets found"

View File

@@ -1,140 +0,0 @@
name: Tests
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
jobs:
unit-tests:
name: Unit Tests
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Install bats
run: brew install bats-core
- name: Run all test suites
run: |
echo "Running all test suites..."
bats tests/*.bats --formatter tap
echo ""
echo "Test summary:"
echo " Total test files: $(ls tests/*.bats | wc -l | tr -d ' ')"
echo " Total tests: $(grep -c "^@test" tests/*.bats | awk -F: '{sum+=$2} END {print sum}')"
echo "✓ All tests passed"
go-tests:
name: Go Tests
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Build Go binaries
run: |
echo "Building Go binaries..."
go build ./...
echo "✓ Build successful"
- name: Run go vet
run: |
echo "Running go vet..."
go vet ./cmd/...
echo "✓ Vet passed"
- name: Run go test
run: |
echo "Running go test..."
go test ./cmd/...
echo "✓ Go tests passed"
integration-tests:
name: Integration Tests
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: brew install coreutils
- name: Test module loading
run: |
echo "Testing module loading..."
bash -c 'source lib/core/common.sh && echo "✓ Modules loaded successfully"'
- name: Test clean --dry-run
run: |
echo "Testing clean --dry-run..."
./bin/clean.sh --dry-run
echo "✓ Clean dry-run completed"
- name: Test installation
run: |
echo "Testing installation script..."
./install.sh --prefix /tmp/mole-test
test -f /tmp/mole-test/mole
echo "✓ Installation successful"
compatibility:
name: macOS Compatibility
strategy:
matrix:
os: [macos-14, macos-15]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Test on ${{ matrix.os }}
run: |
echo "Testing on ${{ matrix.os }}..."
bash -n mole
source lib/core/common.sh
echo "✓ Successfully loaded on ${{ matrix.os }}"
security:
name: Security Checks
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Check for unsafe rm usage
run: |
echo "Checking for unsafe rm patterns..."
if grep -r "rm -rf" --include="*.sh" lib/ | grep -v "safe_remove\|validate_path\|# "; then
echo "✗ Unsafe rm -rf usage found"
exit 1
fi
echo "✓ No unsafe rm usage found"
- name: Verify app protection
run: |
echo "Verifying critical file protection..."
bash -c '
source lib/core/common.sh
if should_protect_from_uninstall "com.apple.Safari"; then
echo "✓ Safari is protected"
else
echo "✗ Safari protection failed"
exit 1
fi
'
- name: Check for secrets
run: |
echo "Checking for hardcoded secrets..."
matches=$(grep -r "password\|secret\|api_key" --include="*.sh" . \
| grep -v "# \|test" \
| grep -v -E "lib/core/sudo\.sh|lib/core/app_protection\.sh|lib/clean/user\.sh|lib/clean/brew\.sh" || true)
if [[ -n "$matches" ]]; then
echo "$matches"
echo "✗ Potential secrets found"
exit 1
fi
echo "✓ No secrets found"

4
.gitignore vendored
View File

@@ -43,6 +43,7 @@ temp/
# AI Assistant Instructions # AI Assistant Instructions
.claude/ .claude/
.gemini/ .gemini/
.kiro/
CLAUDE.md CLAUDE.md
GEMINI.md GEMINI.md
.cursorrules .cursorrules
@@ -51,8 +52,11 @@ GEMINI.md
cmd/analyze/analyze cmd/analyze/analyze
cmd/status/status cmd/status/status
/status /status
/analyze
mole-analyze mole-analyze
# Note: bin/analyze-go and bin/status-go are released binaries and should be tracked # Note: bin/analyze-go and bin/status-go are released binaries and should be tracked
bin/analyze-darwin-*
bin/status-darwin-*
# Swift / Xcode # Swift / Xcode
.build/ .build/

157
AGENT.md
View File

@@ -1,130 +1,49 @@
# Mole AI Agent Documentation # Mole AI Agent Notes
> **READ THIS FIRST**: This file serves as the single source of truth for any AI agent trying to work on the Mole repository. It aggregates architectural context, development workflows, and behavioral guidelines. Use this file as the single source of truth for how to work on Mole.
## 1. Philosophy & Guidelines ## Principles
### Core Philosophy - Safety first: never risk user data or system stability.
- Never run destructive operations that could break the user's machine.
- Do not delete user-important files; cleanup must be conservative and reversible.
- Always use `safe_*` helpers (no raw `rm -rf`).
- Keep changes small and confirm uncertain behavior.
- Follow the local code style in the file you are editing (Bash 3.2 compatible).
- Comments must be English, concise, and intent-focused.
- Use comments for safety boundaries, non-obvious logic, or flow context.
- Entry scripts start with ~3 short lines describing purpose/behavior.
- Shell code must use shell-only helpers (no Python).
- Go code must use Go-only helpers (no Python).
- Do not remove installer flags `--prefix`/`--config` (update flow depends on them).
- Do not commit or submit code changes unless explicitly requested.
- You may use `gh` to access GitHub information when needed.
- **Safety First**: Never risk user data. Always use `safe_*` wrappers. When in doubt, ask. ## Architecture
- **Incremental Progress**: Break complex tasks into manageable stages.
- **Clear Intent**: Prioritize readability and maintainability over clever hacks.
- **Native Performance**: Use Go for heavy lifting (scanning), Bash for system glue.
### Eight Honors and Eight Shames - `mole`: main CLI entrypoint (menu + command routing).
- `mo`: CLI alias wrapper.
- `install.sh`: manual installer/updater (download/build + install).
- `bin/`: command entry points (`clean.sh`, `uninstall.sh`, `optimize.sh`, `purge.sh`, `touchid.sh`,
`analyze.sh`, `status.sh`).
- `lib/`: shell logic (`core/`, `clean/`, `ui/`).
- `cmd/`: Go apps (`analyze/`, `status/`).
- `scripts/`: build/test helpers.
- `tests/`: BATS integration tests.
- **Shame** in guessing APIs, **Honor** in careful research. ## Workflow
- **Shame** in vague execution, **Honor** in seeking confirmation.
- **Shame** in assuming business logic, **Honor** in human verification.
- **Shame** in creating interfaces, **Honor** in reusing existing ones.
- **Shame** in skipping validation, **Honor** in proactive testing.
- **Shame** in breaking architecture, **Honor** in following specifications.
- **Shame** in pretending to understand, **Honor** in honest ignorance.
- **Shame** in blind modification, **Honor** in careful refactoring.
### Quality Standards - Shell work: add logic under `lib/`, call from `bin/`.
- Go work: edit `cmd/<app>/*.go`.
- Prefer dry-run modes while validating cleanup behavior.
- **English Only**: Comments and code must be in English. ## Build & Test
- **No Unnecessary Comments**: Code should be self-explanatory.
- **Pure Shell Style**: Use `[[ ]]` over `[ ]`, avoid `local var` assignments on definition line if exit code matters.
- **Go Formatting**: Always run `gofmt` (or let the build script do it).
## 2. Project Identity - `./scripts/test.sh` runs unit/go/integration tests.
- `make build` builds Go binaries for local development.
- `go run ./cmd/analyze` for dev runs without building.
- **Name**: Mole ## Key Behaviors
- **Purpose**: A lightweight, robust macOS cleanup and system analysis tool.
- **Core Value**: Native, fast, safe, and dependency-free (pure Bash + static Go binary).
- **Mechanism**:
- **Cleaning**: Pure Bash scripts for transparency and safety.
- **Analysis**: High-concurrency Go TUI (Bubble Tea) for disk scanning.
- **Monitoring**: Real-time Go TUI for system status.
## 3. Technology Stack - `mole update` uses `install.sh` with `--prefix`/`--config`; keep these flags.
- Cleanup must go through `safe_*` and respect protection lists.
- **Shell**: Bash 3.2+ (macOS default compatible).
- **Go**: Latest Stable (Bubble Tea framework).
- **Testing**:
- **Shell**: `bats-core`, `shellcheck`.
- **Go**: Native `testing` package.
## 4. Repository Architecture
### Directory Structure
- **`bin/`**: Standalone entry points.
- `mole`: Main CLI wrapper.
- `clean.sh`, `uninstall.sh`: Logic wrappers calling `lib/`.
- **`cmd/`**: Go applications.
- `analyze/`: Disk space analyzer (concurrent, TUI).
- `status/`: System monitor (TUI).
- **`lib/`**: Core Shell Logic.
- `core/`: Low-level utilities (logging, `safe_remove`, sudo helpers).
- `clean/`: Domain-specific cleanup tasks (`brew`, `caches`, `system`).
- `ui/`: Reusable TUI components (`menu_paginated.sh`).
- **`scripts/`**: Development tools (`run-tests.sh`, `build-analyze.sh`).
- **`tests/`**: BATS integration tests.
## 5. Key Workflows
### Development
1. **Understand**: Read `lib/core/` to know what tools are available.
2. **Implement**:
- For Shell: Add functions to `lib/`, source them in `bin/`.
- For Go: Edit `cmd/app/*.go`.
3. **Verify**: Use dry-run modes first.
**Commands**:
- `./scripts/run-tests.sh`: **Run EVERYTHING** (Lint, Syntax, Unit, Go).
- `./bin/clean.sh --dry-run`: Test cleanup logic safely.
- `go run ./cmd/analyze`: Run analyzer in dev mode.
### Building
- `./scripts/build-analyze.sh`: Compiles `analyze-go` binary (Universal).
- `./scripts/build-status.sh`: Compiles `status-go` binary.
### Release
- Versions managed via git tags.
- Build scripts embed version info into binaries.
## 6. Implementation Details
### Safety System (`lib/core/file_ops.sh`)
- **Crucial**: Never use `rm -rf` directly.
- **Use**:
- `safe_remove "/path"`
- `safe_find_delete "/path" "*.log" 7 "f"`
- **Protection**:
- `validate_path_for_deletion` prevents root/system deletion.
- `checks` ensure path is absolute and safe.
### Go Concurrency (`cmd/analyze`)
- **Worker Pool**: Tuned dynamically (16-64 workers) to respect system load.
- **Throttling**: UI updates throttled (every 100 items) to keep TUI responsive (80ms tick).
- **Memory**: Uses Heaps for top-file tracking to minimize RAM usage.
### TUI Unification
- **Keybindings**: `j/k` (Nav), `space` (Select), `enter` (Action), `R` (Refresh).
- **Style**: Compact footers ` | ` and standard colors defined in `lib/core/base.sh` or Go constants.
## 7. Common AI Tasks
- **Adding a Cleanup Task**:
1. Create/Edit `lib/clean/topic.sh`.
2. Define `clean_topic()`.
3. Register in `lib/optimize/tasks.sh` or `bin/clean.sh`.
4. **MUST** use `safe_*` functions.
- **Modifying Go UI**:
1. Update `model` struct in `main.go`.
2. Update `View()` in `view.go`.
3. Run `./scripts/build-analyze.sh` to test.
- **Fixing a Bug**:
1. Reproduce with a new BATS test in `tests/`.
2. Fix logic.
3. Verify with `./scripts/run-tests.sh`.

View File

@@ -9,26 +9,16 @@ brew install shfmt shellcheck bats-core
## Development ## Development
Run all quality checks before committing: Run quality checks before committing (auto-formats code):
```bash ```bash
./scripts/check.sh ./scripts/check.sh
``` ```
This command runs: Run tests:
- Code formatting check
- ShellCheck linting
- Unit tests
Individual commands:
```bash ```bash
# Format code ./scripts/test.sh
./scripts/format.sh
# Run tests only
./tests/run.sh
``` ```
## Code Style ## Code Style
@@ -54,8 +44,8 @@ Config: `.editorconfig` and `.shellcheckrc`
# Single file/directory # Single file/directory
safe_remove "/path/to/file" safe_remove "/path/to/file"
# Batch delete with find # Purge files older than 7 days
safe_find_delete "$dir" "*.log" 7 "f" # files older than 7 days safe_find_delete "$dir" "*.log" 7 "f"
# With sudo # With sudo
safe_sudo_remove "/Library/Caches/com.example" safe_sudo_remove "/Library/Caches/com.example"
@@ -137,7 +127,7 @@ Format: `[MODULE_NAME] message` output to stderr.
- macOS 10.14 or newer, works on Intel and Apple Silicon - macOS 10.14 or newer, works on Intel and Apple Silicon
- Default macOS Bash 3.2+ plus administrator privileges for cleanup tasks - Default macOS Bash 3.2+ plus administrator privileges for cleanup tasks
- Install Command Line Tools with `xcode-select --install` for curl, tar, and related utilities - Install Command Line Tools with `xcode-select --install` for curl, tar, and related utilities
- Go 1.24+ required when building the `mo status` or `mo analyze` TUI binaries locally - Go 1.24+ is required to build the `mo status` or `mo analyze` TUI binaries locally.
## Go Components ## Go Components
@@ -154,14 +144,28 @@ Format: `[MODULE_NAME] message` output to stderr.
- Format code with `gofmt -w ./cmd/...` - Format code with `gofmt -w ./cmd/...`
- Run `go vet ./cmd/...` to check for issues - Run `go vet ./cmd/...` to check for issues
- Build with `go build ./...` to verify all packages compile - Build with `go build ./...` to verify all packages compile
- Build universal binaries via `./scripts/build-status.sh` and `./scripts/build-analyze.sh`
**Building Go Binaries:**
For local development:
```bash
# Build binaries for current architecture
make build
# Or run directly without building
go run ./cmd/analyze
go run ./cmd/status
```
For releases, GitHub Actions builds architecture-specific binaries automatically.
**Guidelines:** **Guidelines:**
- Keep files focused on single responsibility - Keep files focused on single responsibility
- Extract constants instead of magic numbers - Extract constants instead of magic numbers
- Use context for timeout control on external commands - Use context for timeout control on external commands
- Add comments explaining why, not what - Add comments explaining **why** something is done, not just **what** is being done.
## Pull Requests ## Pull Requests

40
Makefile Normal file
View File

@@ -0,0 +1,40 @@
# Makefile for Mole
.PHONY: all build clean release
# Output directory
BIN_DIR := bin
# Binaries
ANALYZE := analyze
STATUS := status
# Source directories
ANALYZE_SRC := ./cmd/analyze
STATUS_SRC := ./cmd/status
# Build flags
LDFLAGS := -s -w
all: build
# Local build (current architecture)
build:
@echo "Building for local architecture..."
go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-go $(ANALYZE_SRC)
go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-go $(STATUS_SRC)
# Release build targets (run on native architectures for CGO support)
release-amd64:
@echo "Building release binaries (amd64)..."
GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-amd64 $(ANALYZE_SRC)
GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-amd64 $(STATUS_SRC)
release-arm64:
@echo "Building release binaries (arm64)..."
GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-arm64 $(ANALYZE_SRC)
GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-arm64 $(STATUS_SRC)
clean:
@echo "Cleaning binaries..."
rm -f $(BIN_DIR)/$(ANALYZE)-* $(BIN_DIR)/$(STATUS)-* $(BIN_DIR)/$(ANALYZE)-go $(BIN_DIR)/$(STATUS)-go

View File

@@ -18,24 +18,25 @@
## Features ## Features
- **All-in-one toolkit** combining the power of CleanMyMac, AppCleaner, DaisyDisk, Sensei, and iStat in one **trusted binary** - **Unified toolkit**: Consolidated features of CleanMyMac, AppCleaner, DaisyDisk, and iStat into a **single binary**
- **Deep cleanup** scans and removes caches, logs, browser leftovers, and junk to **reclaim tens of gigabytes** - **Deep cleaning**: Scans and removes caches, logs, and browser leftovers to **reclaim gigabytes of space**
- **Smart uninstall** completely removes apps including launch agents, preferences, caches, and **hidden leftovers** - **Smart uninstaller**: Thoroughly removes apps along with launch agents, preferences, and **hidden remnants**
- **Disk insight + optimization** visualizes usage, handles large files, **rebuilds caches**, cleans swap, and refreshes services - **Disk insights**: Visualizes usage, manages large files, **rebuilds caches**, and refreshes system services
- **Live status** monitors CPU, GPU, memory, disk, network, battery, and proxy stats to **diagnose issues** - **Live monitoring**: Real-time stats for CPU, GPU, memory, disk, and network to **diagnose performance issues**
## Quick Start ## Quick Start
**Installation:** **Install by Brew, recommended:**
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash brew install mole
``` ```
Or via Homebrew: **or by Script, for older macOS or latest code:**
```bash ```bash
brew install tw93/tap/mole # Use for older macOS or latest code; add '-s latest' for newest, or '-s 1.17.0' for a fixed version.
curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash
``` ```
**Run:** **Run:**
@@ -47,18 +48,21 @@ mo uninstall # Remove apps + leftovers
mo optimize # Refresh caches & services mo optimize # Refresh caches & services
mo analyze # Visual disk explorer mo analyze # Visual disk explorer
mo status # Live system health dashboard mo status # Live system health dashboard
mo purge # Clean project build artifacts
mo touchid # Configure Touch ID for sudo mo touchid # Configure Touch ID for sudo
mo completion # Setup shell tab completion
mo update # Update Mole mo update # Update Mole
mo remove # Remove Mole from system mo remove # Remove Mole from system
mo --help # Show help mo --help # Show help
mo --version # Show installed version mo --version # Show installed version
mo clean --dry-run # Preview cleanup plan mo clean --dry-run # Preview the cleanup plan
mo clean --whitelist # Adjust protected caches mo clean --whitelist # Manage protected caches
mo uninstall --force-rescan # Rescan apps and refresh cache
mo optimize --whitelist # Adjust protected optimization items
mo optimize --dry-run # Preview optimization actions
mo optimize --whitelist # Manage protected optimization rules
mo purge --paths # Configure project scan directories
``` ```
## Tips ## Tips
@@ -67,6 +71,7 @@ mo optimize --whitelist # Adjust protected optimization items
- **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 our [Security Audit](SECURITY_AUDIT.md). Preview changes with `mo clean --dry-run`.
- **Whitelist**: Manage protected paths with `mo clean --whitelist`. - **Whitelist**: Manage protected paths with `mo clean --whitelist`.
- **Touch ID**: Enable Touch ID for sudo commands by running `mo touchid`. - **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`). - **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`). - **Debug**: View detailed logs by appending the `--debug` flag (e.g., `mo clean --debug`).
@@ -181,6 +186,42 @@ Proxy HTTP · 192.168.1.100 Terminal ▮▯▯▯▯ 12.5%
Health score based on CPU, memory, disk, temperature, and I/O load. Color-coded by range. Health score based on CPU, memory, disk, temperature, and I/O load. Color-coded by range.
### Project Artifact Purge
Clean old build artifacts (`node_modules`, `target`, `build`, `dist`, etc.) from your projects to free up disk space.
```bash
mo purge
Select Categories to Clean - 18.5GB (8 selected)
➤ ● my-react-app 3.2GB | node_modules
● old-project 2.8GB | node_modules
● rust-app 4.1GB | target
● next-blog 1.9GB | node_modules
○ current-work 856MB | node_modules | Recent
● django-api 2.3GB | venv
● vue-dashboard 1.7GB | 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.
<details>
<summary><strong>Custom Scan Paths</strong></summary>
Run `mo purge --paths` to configure which directories to scan, or edit `~/.config/mole/purge_paths` directly:
```shell
~/Documents/MyProjects
~/Work/ClientA
~/Work/ClientB
```
When custom paths are configured, only those directories are scanned. Otherwise, defaults to `~/Projects`, `~/GitHub`, `~/dev`, etc.
</details>
## Quick Launchers ## Quick Launchers
Launch Mole commands instantly from Raycast or Alfred: Launch Mole commands instantly from Raycast or Alfred:
@@ -189,7 +230,15 @@ 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`. Finds your terminal automatically or set `MO_LAUNCHER_APP=<name>` to override. For Raycast, search "Reload Script Directories" to load new commands. 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.
## Community Love
<p align="center">
<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.
## Support ## Support
@@ -197,7 +246,6 @@ Adds 5 commands: `clean`, `uninstall`, `optimize`, `analyze`, `status`. Finds yo
- If Mole saved you space, consider starring the repo or sharing it with friends who need a cleaner Mac. - If Mole saved you space, consider starring the repo or sharing it with friends who need a cleaner Mac.
- Have ideas or fixes? Open an issue or PR to help shape Mole's future with the community. - Have ideas or fixes? Open an issue or PR to help shape Mole's future with the community.
- 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 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.
## License ## License

View File

@@ -1,100 +1,359 @@
# Mole Security Audit Report # Mole Security Audit Report
**Date:** December 14, 2025 <div align="center">
**Audited Version:** Current `main` branch (V1.12.25) **Security Audit & Compliance Report**
**Status:** Passed Version 1.17.0 | December 31, 2025
## Security Philosophy: "Do No Harm" ---
Mole is designed with a **Zero Trust** architecture regarding file operations. Every request to modify the filesystem is treated as potentially dangerous until strictly validated. Our primary directive is to prioritize system stability over aggressive cleaning—we would rather leave 1GB of junk than delete 1KB of critical user data. **Audit Status:** PASSED | **Risk Level:** LOW
## 1. Multi-Layered Defense Architecture (Automated Core) </div>
Mole's automated shell-based operations (Clean, Optimize, Uninstall) do not execute raw commands directly. All operations pass through a hardened middleware layer (`lib/core/file_ops.sh`). ---
- **Layer 1: Input Sanitization** ## Table of Contents
Before any operation reaches the execution stage, the target path is sanitized:
- **Absolute Path Enforcement**: Relative paths (e.g., `../foo`) are strictly rejected to prevent path traversal attacks.
- **Control Character Filtering**: Paths containing hidden control characters or newlines are blocked.
- **Empty Variable Protection**: Guards against shell scripting errors where an empty variable could result in `rm -rf /`.
- **Layer 2: The "Iron Dome" (Path Validation)** 1. [Audit Overview](#audit-overview)
A centralized validation logic explicitly blocks operations on critical system hierarchies within the shell core, even with `sudo` privileges: 2. [Security Philosophy](#security-philosophy)
- `/` (Root) 3. [Threat Model](#threat-model)
- `/System` and `/System/*` 4. [Defense Architecture](#defense-architecture)
- `/bin`, `/sbin`, `/usr`, `/usr/bin`, `/usr/sbin` 5. [Safety Mechanisms](#safety-mechanisms)
- `/etc`, `/var` 6. [User Controls](#user-controls)
- `/Library/Extensions` 7. [Testing & Compliance](#testing--compliance)
8. [Dependencies](#dependencies)
- **Layer 3: Symlink Failsafe** ---
For privileged (`sudo`) operations, Mole performs a pre-flight check to verify if the target is a **Symbolic Link**.
- **Risk**: A malicious or accidental symlink could point from a cache folder to a system file.
- **Defense**: Mole explicitly refuses to recursively delete symbolic links in privileged mode.
## 2. Interactive Analyzer Safety (Go Architecture) ## Audit Overview
The interactive analyzer (`mo analyze`) operates on a different security model focused on manual user control: | Attribute | Details |
|-----------|---------|
| Audit Date | December 31, 2025 |
| Audit Conclusion | **PASSED** |
| Mole Version | V1.17.0 |
| Audited Branch | `main` (HEAD) |
| Scope | Shell scripts, Go binaries, Configuration |
| Methodology | Static analysis, Threat modeling, Code review |
| Review Cycle | Every 6 months or after major feature additions |
| Next Review | June 2026 |
- **Standard User Permissions**: The tool runs with the invoking user's standard permissions. It respects macOS System Integrity Protection (SIP) and filesystem permissions. **Key Findings:**
- **Manual Confirmation**: Deletions are not automated; they require explicit user selection and confirmation.
- **OS-Level Enforcement**: Unlike the automated scripts, the analyzer relies on the operating system's built-in protections (e.g., inability to delete `/System` due to Read-Only Volume or SIP) rather than a hardcoded application-level blocklist.
## 3. Conservative Cleaning Logic - Multi-layered validation prevents critical system modifications
- Conservative cleaning logic with 60-day dormancy rules
- Comprehensive protection for VPN, AI tools, and system components
- Atomic operations with crash recovery mechanisms
- Full user control with dry-run and whitelist capabilities
Mole's "Smart Uninstall" and orphan detection (`lib/clean/apps.sh`) are intentionally conservative: ---
- **Orphaned Data: The "60-Day Rule"** ## Security Philosophy
1. **Verification**: An app is confirmed "uninstalled" only if it is completely missing from `/Applications`, `~/Applications`, and `/System/Applications`.
2. **Dormancy Check**: Associated data folders are only flagged for removal if they have not been modified for **at least 60 days**.
3. **Vendor Whitelist**: A hardcoded whitelist protects shared resources from major vendors (Adobe, Microsoft, Google, etc.) to prevent breaking software suites.
- **Active Uninstallation Heuristics** **Core Principle: "Do No Harm"**
When a user explicitly selects an app for uninstallation, Mole employs advanced heuristics to find scattered remnants (e.g., "Visual Studio Code" -> `~/.vscode`, `~/Library/Application Support/VisualStudioCode`).
- **Sanitized Name Matching**: We search for app name variations to catch non-standard folder naming.
- **Safety Constraints**: Fuzzy matching and sanitized name searches are **strictly disabled** for app names shorter than 3 characters to prevent false positives.
- **System Scope**: Mole scans specific system-level directories (`/Library/LaunchAgents`, etc.) for related components.
- **System Integrity Protection (SIP) Awareness** Mole operates under a **Zero Trust** architecture for all filesystem operations. Every modification request is treated as potentially dangerous until passing strict validation.
Mole respects macOS SIP. It detects if SIP is enabled and automatically skips protected directories (like `/Library/Updates`) to avoid triggering permission errors.
- **Time Machine Preservation** **Guiding Priorities:**
Before cleaning failed backups, Mole checks for the `backupd` process. If a backup is currently running, the cleanup task is strictly **aborted** to prevent data corruption.
- **VPN & Proxy Protection** 1. **System Stability First** - Prefer leaving 1GB of junk over deleting 1KB of critical data
Mole includes a comprehensive protection layer for VPN and Proxy applications (e.g., Shadowsocks, V2Ray, Tailscale). It protects both their application bundles and data directories from automated cleanup to prevent network configuration loss. 2. **Conservative by Default** - Require explicit user confirmation for high-risk operations
3. **Fail Safe** - When in doubt, abort rather than proceed
4. **Transparency** - All operations are logged and can be previewed via dry-run mode
- **AI & LLM Data Protection (New in v1.12.25)** ---
Mole now explicitly protects data for AI tools (Cursor, Claude, ChatGPT, Ollama, LM Studio, etc.). Both the automated cleaning logic (`bin/clean.sh`) and orphan detection (`lib/core/app_protection.sh`) exclude these applications to prevent loss of:
- Local LLM models (which can be gigabytes in size).
- Authentication tokens and session states.
- Chat history and local configurations.
## 4. Atomic Operations & Crash Safety ## Threat Model
We anticipate that scripts can be interrupted (e.g., power loss, `Ctrl+C`). ### Attack Vectors & Mitigations
- **Network Interface Reset**: Wi-Fi and AirDrop resets use **atomic execution blocks**. | Threat | Risk Level | Mitigation | Status |
- **Swap Clearing**: Swap files are reset by securely restarting the `dynamic_pager` daemon. We intentionally avoid manual `rm` operations on swap files to prevent instability during high memory pressure. |--------|------------|------------|--------|
| Accidental System File Deletion | Critical | Multi-layer path validation, system directory blocklist | Mitigated |
| Path Traversal Attack | High | Absolute path enforcement, relative path rejection | Mitigated |
| Symlink Exploitation | High | Symlink detection in privileged mode | Mitigated |
| Command Injection | High | Control character filtering, strict validation | Mitigated |
| Empty Variable Deletion | High | Empty path validation, defensive checks | Mitigated |
| Race Conditions | Medium | Atomic operations, process isolation | Mitigated |
| Network Mount Hangs | Medium | Timeout protection, volume type detection | Mitigated |
| Privilege Escalation | Medium | Restricted sudo scope, user home validation | Mitigated |
| False Positive Deletion | Medium | 3-char minimum, fuzzy matching disabled | Mitigated |
| VPN Configuration Loss | Medium | Comprehensive VPN/proxy whitelist | Mitigated |
## 5. User Control & Transparency ---
- **Dry-Run Mode (`--dry-run`)**: Simulates the entire cleanup process, listing every single file and byte that *would* be removed, without touching the disk. ## Defense Architecture
- **Custom Whitelists**: Users can define their own immutable paths in `~/.config/mole/whitelist`.
## 6. Dependency Audit ### Multi-Layered Validation System
- **System Binaries (Shell Core)** All automated operations pass through hardened middleware (`lib/core/file_ops.sh`) with 4 validation layers:
Mole relies on standard, battle-tested macOS binaries for critical tasks:
- `plutil`: Used to validate `.plist` integrity.
- `tmutil`: Used for safe interaction with Time Machine.
- `dscacheutil`: Used for system-compliant cache rebuilding.
- **Go Dependencies (Interactive Tools)** #### Layer 1: Input Sanitization
The compiled Go binary (`analyze-go`) includes the following libraries:
- `bubbletea` & `lipgloss`: UI framework (Charm).
- `gopsutil`: System metrics collection.
- `xxhash`: Efficient hashing.
*This document certifies that Mole's architecture implements industry-standard defensive programming practices to ensure the safety and integrity of your Mac.* | Control | Protection Against |
|---------|---------------------|
| Absolute Path Enforcement | Path traversal attacks (`../etc`) |
| Control Character Filtering | Command injection (`\n`, `\r`, `\0`) |
| Empty Variable Protection | Accidental `rm -rf /` |
| Secure Temp Workspaces | Data leakage, race conditions |
**Code:** `lib/core/file_ops.sh:validate_path_for_deletion()`
#### Layer 2: System Path Protection ("Iron Dome")
Even with `sudo`, these paths are **unconditionally blocked**:
```bash
/ # Root filesystem
/System # macOS system files
/bin, /sbin, /usr # Core binaries
/etc, /var # System configuration
/Library/Extensions # Kernel extensions
```
**Exception:** `/System/Library/Caches/com.apple.coresymbolicationd/data` (safe, rebuildable cache)
**Code:** `lib/core/file_ops.sh:60-78`
#### Layer 3: Symlink Detection
For privileged operations, pre-flight checks prevent symlink-based attacks:
- Detects symlinks pointing from cache folders to system files
- Refuses recursive deletion of symbolic links in sudo mode
- Validates real path vs symlink target
**Code:** `lib/core/file_ops.sh:safe_sudo_recursive_delete()`
#### Layer 4: Permission Management
When running with `sudo`:
- Auto-corrects ownership back to user (`chown -R`)
- Operations restricted to user's home directory
- Multiple validation checkpoints
### Interactive Analyzer (Go)
The analyzer (`mo analyze`) uses a different security model:
- Runs with standard user permissions only
- Respects macOS System Integrity Protection (SIP)
- All deletions require explicit user confirmation
- OS-level enforcement (cannot delete `/System` due to Read-Only Volume)
**Code:** `cmd/analyze/*.go`
---
## Safety Mechanisms
### Conservative Cleaning Logic
#### The "60-Day Rule" for Orphaned Data
| Step | Verification | Criterion |
|------|--------------|-----------|
| 1. App Check | All installation locations | Must be missing from `/Applications`, `~/Applications`, `/System/Applications` |
| 2. Dormancy | Modification timestamps | Untouched for ≥60 days |
| 3. Vendor Whitelist | Cross-reference database | Adobe, Microsoft, Google resources protected |
**Code:** `lib/clean/apps.sh:orphan_detection()`
#### Active Uninstallation Heuristics
For user-selected app removal:
- **Sanitized Name Matching:** "Visual Studio Code" → `VisualStudioCode`, `.vscode`
- **Safety Limit:** 3-char minimum (prevents "Go" matching "Google")
- **Disabled:** Fuzzy matching, wildcard expansion for short names
- **User Confirmation:** Required before deletion
**Code:** `lib/clean/apps.sh:uninstall_app()`
#### System Protection Policies
| Protected Category | Scope | Reason |
|--------------------|-------|--------|
| System Integrity Protection | `/Library/Updates`, `/System/*` | Respects macOS Read-Only Volume |
| Spotlight & System UI | `~/Library/Metadata/CoreSpotlight` | Prevents UI corruption |
| System Components | Control Center, System Settings, TCC | Centralized detection via `is_critical_system_component()` |
| Time Machine | Local snapshots, backups | Checks `backupd` process, aborts if active |
| VPN & Proxy | Shadowsocks, V2Ray, Tailscale, Clash | Protects network configs |
| AI & LLM Tools | Cursor, Claude, ChatGPT, Ollama, LM Studio | Protects models, tokens, sessions |
| Startup Items | `com.apple.*` LaunchAgents/Daemons | System items unconditionally skipped |
**Orphaned Helper Cleanup (`opt_startup_items_cleanup`):**
Removes LaunchAgents/Daemons whose associated app has been uninstalled:
- Checks `AssociatedBundleIdentifiers` to detect orphans
- Skips all `com.apple.*` system items
- Skips paths under `/System/*`, `/usr/bin/*`, `/usr/lib/*`, `/usr/sbin/*`, `/Library/Apple/*`
- Uses `safe_remove` / `safe_sudo_remove` with path validation
- Unloads service via `launchctl` before deletion
- `mdfind` operations have 10-second timeout protection
**Code:** `lib/optimize/tasks.sh:opt_startup_items_cleanup()`
### Crash Safety & Atomic Operations
| Operation | Safety Mechanism | Recovery Behavior |
|-----------|------------------|-------------------|
| Network Interface Reset | Atomic execution blocks | Wi-Fi/AirDrop restored to pre-operation state |
| Swap Clearing | Daemon restart | `dynamic_pager` handles recovery safely |
| Volume Scanning | Timeout + filesystem check | Auto-skip unresponsive NFS/SMB/AFP mounts |
| Homebrew Cache | Pre-flight size check | Skip if <50MB (avoids 30-120s delay) |
| Network Volume Check | `diskutil info` with timeout | Prevents hangs on slow/dead mounts |
| SQLite Vacuum | App-running check + 20s timeout | Skips if Mail/Safari/Messages running |
| dyld Cache Update | 24-hour freshness check + 180s timeout | Skips if recently updated |
| App Bundle Search | 10s timeout on mdfind | Fallback to standard paths |
**Timeout Example:**
```bash
run_with_timeout 5 diskutil info "$mount_point" || skip_volume
```
**Code:** `lib/core/base.sh:run_with_timeout()`, `lib/optimize/*.sh`
---
## User Controls
### Dry-Run Mode
**Command:** `mo clean --dry-run` | `mo optimize --dry-run`
**Behavior:**
- Simulates entire operation without filesystem modifications
- Lists every file/directory that **would** be deleted
- Calculates total space that **would** be freed
- Zero risk - no actual deletion commands executed
### Custom Whitelists
**File:** `~/.config/mole/whitelist`
**Format:**
```bash
# One path per line - exact matches only
/Users/username/important-cache
~/Library/Application Support/CriticalApp
```
- Paths are **unconditionally protected**
- Applies to all operations (clean, optimize, uninstall)
- Supports absolute paths and `~` expansion
**Code:** `lib/core/file_ops.sh:is_whitelisted()`
### Interactive Confirmations
Required for:
- Uninstalling system-scope applications
- Removing large data directories (>1GB)
- Deleting items from shared vendor folders
---
## Testing & Compliance
### Test Coverage
Mole uses **BATS (Bash Automated Testing System)** for automated testing.
| Test Category | Coverage | Key Tests |
|---------------|----------|-----------|
| Core File Operations | 95% | Path validation, symlink detection, permissions |
| Cleaning Logic | 87% | Orphan detection, 60-day rule, vendor whitelist |
| Optimization | 82% | Cache cleanup, timeouts |
| System Maintenance | 90% | Time Machine, network volumes, crash recovery |
| Security Controls | 100% | Path traversal, command injection, symlinks |
**Total:** 180+ tests | **Overall Coverage:** ~88%
**Test Execution:**
```bash
bats tests/ # Run all tests
bats tests/security.bats # Run specific suite
```
### Standards Compliance
| Standard | Implementation |
|----------|----------------|
| OWASP Secure Coding | Input validation, least privilege, defense-in-depth |
| CWE-22 (Path Traversal) | Absolute path enforcement, `../` rejection |
| 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 |
### Security Development Lifecycle
- **Static Analysis:** shellcheck for all shell scripts
- **Code Review:** All changes reviewed by maintainers
- **Dependency Scanning:** Minimal external dependencies, all vetted
### Known Limitations
| Limitation | Impact | Mitigation |
|------------|--------|------------|
| Requires `sudo` for system caches | Initial friction | Clear documentation |
| 60-day rule may delay cleanup | Some orphans remain longer | Manual `mo uninstall` available |
| No undo functionality | Deleted files unrecoverable | Dry-run mode, warnings |
| English-only name matching | May miss non-English apps | Bundle ID fallback |
**Intentionally Out of Scope (Safety):**
- Automatic deletion of user documents/media
- Encryption key stores or password managers
- System configuration files (`/etc/*`)
- Browser history or cookies
- Git repository cleanup
---
## Dependencies
### System Binaries
Mole relies on standard macOS system binaries (all SIP-protected):
| Binary | Purpose | Fallback |
|--------|---------|----------|
| `plutil` | Validate `.plist` integrity | Skip invalid plists |
| `tmutil` | Time Machine interaction | Skip TM cleanup |
| `dscacheutil` | System cache rebuilding | Optional optimization |
| `diskutil` | Volume information | Skip network volumes |
### Go Dependencies (Interactive Tools)
The compiled Go binary (`analyze-go`) includes:
| Library | Version | Purpose | License |
|---------|---------|---------|---------|
| `bubbletea` | v0.23+ | TUI framework | MIT |
| `lipgloss` | v0.6+ | Terminal styling | MIT |
| `gopsutil` | v3.22+ | System metrics | BSD-3 |
| `xxhash` | v2.2+ | Fast hashing | BSD-2 |
**Supply Chain Security:**
- All dependencies pinned to specific versions
- Regular security audits
- No transitive dependencies with known CVEs
- **Automated Releases**: Binaries compiled via GitHub Actions and signed
- **Source Only**: Repository contains no pre-compiled binaries
---
**Certification:** This security audit certifies that Mole implements industry-standard defensive programming practices and adheres to macOS security guidelines. The architecture prioritizes system stability and data integrity over aggressive optimization.
*For security concerns or vulnerability reports, please contact the maintainers via GitHub Issues.*

Binary file not shown.

View File

@@ -1,5 +1,7 @@
#!/bin/bash #!/bin/bash
# Entry point for the Go-based disk analyzer binary bundled with Mole. # Mole - Analyze command.
# Runs the Go disk analyzer UI.
# Uses bundled analyze-go binary.
set -euo pipefail set -euo pipefail

View File

@@ -16,13 +16,20 @@ source "$SCRIPT_DIR/lib/manage/autofix.sh"
source "$SCRIPT_DIR/lib/check/all.sh" source "$SCRIPT_DIR/lib/check/all.sh"
cleanup_all() { cleanup_all() {
stop_inline_spinner 2> /dev/null || true
stop_sudo_session stop_sudo_session
cleanup_temp_files cleanup_temp_files
} }
handle_interrupt() {
cleanup_all
exit 130
}
main() { main() {
# Register unified cleanup handler # Register unified cleanup handler
trap cleanup_all EXIT INT TERM trap cleanup_all EXIT
trap handle_interrupt INT TERM
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
clear clear

File diff suppressed because it is too large Load Diff

250
bin/completion.sh Executable file
View File

@@ -0,0 +1,250 @@
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$ROOT_DIR/lib/core/common.sh"
source "$ROOT_DIR/lib/core/commands.sh"
command_names=()
for entry in "${MOLE_COMMANDS[@]}"; do
command_names+=("${entry%%:*}")
done
command_words="${command_names[*]}"
emit_zsh_subcommands() {
for entry in "${MOLE_COMMANDS[@]}"; do
printf " '%s:%s'\n" "${entry%%:*}" "${entry#*:}"
done
}
emit_fish_completions() {
local cmd="$1"
for entry in "${MOLE_COMMANDS[@]}"; do
local name="${entry%%:*}"
local desc="${entry#*:}"
printf 'complete -c %s -n "__fish_mole_no_subcommand" -a %s -d "%s"\n' "$cmd" "$name" "$desc"
done
printf '\n'
printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a bash -d "generate bash completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a zsh -d "generate zsh completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
}
# Auto-install mode when run without arguments
if [[ $# -eq 0 ]]; then
# Detect current shell
current_shell="${SHELL##*/}"
if [[ -z "$current_shell" ]]; then
current_shell="$(ps -p "$PPID" -o comm= 2> /dev/null | awk '{print $1}')"
fi
completion_name=""
if command -v mole > /dev/null 2>&1; then
completion_name="mole"
elif command -v mo > /dev/null 2>&1; then
completion_name="mo"
fi
case "$current_shell" in
bash)
config_file="${HOME}/.bashrc"
[[ -f "${HOME}/.bash_profile" ]] && config_file="${HOME}/.bash_profile"
# shellcheck disable=SC2016
completion_line='if output="$('"$completion_name"' completion bash 2>/dev/null)"; then eval "$output"; fi'
;;
zsh)
config_file="${HOME}/.zshrc"
# shellcheck disable=SC2016
completion_line='if output="$('"$completion_name"' completion zsh 2>/dev/null)"; then eval "$output"; fi'
;;
fish)
config_file="${HOME}/.config/fish/config.fish"
# shellcheck disable=SC2016
completion_line='set -l output ('"$completion_name"' completion fish 2>/dev/null); and echo "$output" | source'
;;
*)
log_error "Unsupported shell: $current_shell"
echo " mole completion <bash|zsh|fish>"
exit 1
;;
esac
if [[ -z "$completion_name" ]]; then
if [[ -f "$config_file" ]] && grep -Eq "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" 2> /dev/null; then
original_mode=""
original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
temp_file="$(mktemp)"
grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
mv "$temp_file" "$config_file"
if [[ -n "$original_mode" ]]; then
chmod "$original_mode" "$config_file" 2> /dev/null || true
fi
echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed stale completion entries from $config_file"
echo ""
fi
log_error "mole not found in PATH - install Mole before enabling completion"
exit 1
fi
# Check if already installed and normalize to latest line
if [[ -f "$config_file" ]] && grep -Eq "(mole|mo)[[:space:]]+completion" "$config_file" 2> /dev/null; then
original_mode=""
original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
temp_file="$(mktemp)"
grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
mv "$temp_file" "$config_file"
if [[ -n "$original_mode" ]]; then
chmod "$original_mode" "$config_file" 2> /dev/null || true
fi
{
echo ""
echo "# Mole shell completion"
echo "$completion_line"
} >> "$config_file"
echo ""
echo -e "${GREEN}${ICON_SUCCESS}${NC} Shell completion updated in $config_file"
echo ""
exit 0
fi
# Prompt user for installation
echo ""
echo -e "${GRAY}Will add to ${config_file}:${NC}"
echo " $completion_line"
echo ""
echo -ne "${PURPLE}${ICON_ARROW}${NC} Enable completion for ${GREEN}${current_shell}${NC}? ${GRAY}Enter confirm / Q cancel${NC}: "
IFS= read -r -s -n1 key || key=""
drain_pending_input
echo ""
case "$key" in
$'\e' | [Qq] | [Nn])
echo -e "${YELLOW}Cancelled${NC}"
exit 0
;;
"" | $'\n' | $'\r' | [Yy]) ;;
*)
log_error "Invalid key"
exit 1
;;
esac
# Create config file if it doesn't exist
if [[ ! -f "$config_file" ]]; then
mkdir -p "$(dirname "$config_file")"
touch "$config_file"
fi
# Remove previous Mole completion lines to avoid duplicates
if [[ -f "$config_file" ]]; then
original_mode=""
original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
temp_file="$(mktemp)"
grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
mv "$temp_file" "$config_file"
if [[ -n "$original_mode" ]]; then
chmod "$original_mode" "$config_file" 2> /dev/null || true
fi
fi
# Add completion line
{
echo ""
echo "# Mole shell completion"
echo "$completion_line"
} >> "$config_file"
echo -e "${GREEN}${ICON_SUCCESS}${NC} Completion added to $config_file"
echo ""
echo ""
echo -e "${GRAY}To activate now:${NC}"
echo -e " ${GREEN}source $config_file${NC}"
exit 0
fi
case "$1" in
bash)
cat << EOF
_mole_completions()
{
local cur_word prev_word
cur_word="\${COMP_WORDS[\$COMP_CWORD]}"
prev_word="\${COMP_WORDS[\$COMP_CWORD-1]}"
if [ "\$COMP_CWORD" -eq 1 ]; then
COMPREPLY=( \$(compgen -W "$command_words" -- "\$cur_word") )
else
case "\$prev_word" in
completion)
COMPREPLY=( \$(compgen -W "bash zsh fish" -- "\$cur_word") )
;;
*)
COMPREPLY=()
;;
esac
fi
}
complete -F _mole_completions mole mo
EOF
;;
zsh)
printf '#compdef mole mo\n\n'
printf '_mole() {\n'
printf ' local -a subcommands\n'
printf ' subcommands=(\n'
emit_zsh_subcommands
printf ' )\n'
printf " _describe 'subcommand' subcommands\n"
printf '}\n\n'
;;
fish)
printf '# Completions for mole\n'
emit_fish_completions mole
printf '\n# Completions for mo (alias)\n'
emit_fish_completions mo
printf '\nfunction __fish_mole_no_subcommand\n'
printf ' for i in (commandline -opc)\n'
# shellcheck disable=SC2016
printf ' if contains -- $i %s\n' "$command_words"
printf ' return 1\n'
printf ' end\n'
printf ' end\n'
printf ' return 0\n'
printf 'end\n\n'
printf 'function __fish_see_subcommand_path\n'
printf ' string match -q -- "completion" (commandline -opc)[1]\n'
printf 'end\n'
;;
*)
cat << 'EOF'
Usage: mole completion [bash|zsh|fish]
Setup shell tab completion for mole and mo commands.
Auto-install:
mole completion # Auto-detect shell and install
Manual install:
mole completion bash # Generate bash completion script
mole completion zsh # Generate zsh completion script
mole completion fish # Generate fish completion script
Examples:
# Auto-install (recommended)
mole completion
# Manual install - Bash
eval "$(mole completion bash)"
# Manual install - Zsh
eval "$(mole completion zsh)"
# Manual install - Fish
mole completion fish | source
EOF
exit 1
;;
esac

View File

@@ -1,72 +1,66 @@
#!/bin/bash #!/bin/bash
# Mole - Optimize command.
# Runs system maintenance checks and fixes.
# Supports dry-run where applicable.
set -euo pipefail set -euo pipefail
# Fix locale issues (Issue #83) # Fix locale issues.
export LC_ALL=C export LC_ALL=C
export LANG=C export LANG=C
# Load common functions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$SCRIPT_DIR/lib/core/common.sh" source "$SCRIPT_DIR/lib/core/common.sh"
# Clean temp files on exit.
trap cleanup_temp_files EXIT INT TERM
source "$SCRIPT_DIR/lib/core/sudo.sh" source "$SCRIPT_DIR/lib/core/sudo.sh"
source "$SCRIPT_DIR/lib/manage/update.sh" source "$SCRIPT_DIR/lib/manage/update.sh"
source "$SCRIPT_DIR/lib/manage/autofix.sh" source "$SCRIPT_DIR/lib/manage/autofix.sh"
source "$SCRIPT_DIR/lib/optimize/maintenance.sh" source "$SCRIPT_DIR/lib/optimize/maintenance.sh"
source "$SCRIPT_DIR/lib/optimize/tasks.sh" source "$SCRIPT_DIR/lib/optimize/tasks.sh"
source "$SCRIPT_DIR/lib/check/health_json.sh" source "$SCRIPT_DIR/lib/check/health_json.sh"
# Load check modules
source "$SCRIPT_DIR/lib/check/all.sh" source "$SCRIPT_DIR/lib/check/all.sh"
source "$SCRIPT_DIR/lib/manage/whitelist.sh" source "$SCRIPT_DIR/lib/manage/whitelist.sh"
# Colors and icons from common.sh
print_header() { print_header() {
printf '\n' printf '\n'
echo -e "${PURPLE_BOLD}Optimize and Check${NC}" echo -e "${PURPLE_BOLD}Optimize and Check${NC}"
} }
# System check functions (real-time display)
run_system_checks() { run_system_checks() {
# Skip checks in dry-run mode.
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
return 0
fi
unset AUTO_FIX_SUMMARY AUTO_FIX_DETAILS unset AUTO_FIX_SUMMARY AUTO_FIX_DETAILS
echo "" unset MOLE_SECURITY_FIXES_SHOWN
echo -e "${PURPLE_BOLD}System Check${NC}" unset MOLE_SECURITY_FIXES_SKIPPED
echo "" echo ""
# Check updates - real-time display
echo -e "${BLUE}${ICON_ARROW}${NC} System updates"
check_all_updates check_all_updates
echo "" echo ""
# Check health - real-time display
echo -e "${BLUE}${ICON_ARROW}${NC} System health"
check_system_health check_system_health
echo "" echo ""
# Check security - real-time display
echo -e "${BLUE}${ICON_ARROW}${NC} Security posture"
check_all_security check_all_security
if ask_for_security_fixes; then if ask_for_security_fixes; then
perform_security_fixes perform_security_fixes
fi fi
echo "" if [[ "${MOLE_SECURITY_FIXES_SKIPPED:-}" != "true" ]]; then
echo ""
fi
# Check configuration - real-time display
echo -e "${BLUE}${ICON_ARROW}${NC} Configuration"
check_all_config check_all_config
echo "" echo ""
# Show suggestions
show_suggestions show_suggestions
echo ""
# Ask about updates first
if ask_for_updates; then if ask_for_updates; then
perform_updates perform_updates
fi fi
# Ask about auto-fix
if ask_for_auto_fix; then if ask_for_auto_fix; then
perform_auto_fix perform_auto_fix
fi fi
@@ -78,39 +72,40 @@ show_optimization_summary() {
if ((safe_count == 0 && confirm_count == 0)) && [[ -z "${AUTO_FIX_SUMMARY:-}" ]]; then if ((safe_count == 0 && confirm_count == 0)) && [[ -z "${AUTO_FIX_SUMMARY:-}" ]]; then
return return
fi fi
local summary_title="Optimization and Check Complete"
local summary_title
local -a summary_details=() local -a summary_details=()
local total_applied=$((safe_count + confirm_count))
# Optimization results if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
summary_details+=("Optimizations: ${GREEN}${safe_count}${NC} applied, ${YELLOW}${confirm_count}${NC} manual checks") summary_title="Dry Run Complete - No Changes Made"
summary_details+=("Caches refreshed; services restarted; system tuned") summary_details+=("Would apply ${YELLOW}${total_applied:-0}${NC} optimizations")
summary_details+=("Updates & security reviewed across system") summary_details+=("Run without ${YELLOW}--dry-run${NC} to apply these changes")
local summary_line4=""
if [[ -n "${AUTO_FIX_SUMMARY:-}" ]]; then
summary_line4="${AUTO_FIX_SUMMARY}"
if [[ -n "${AUTO_FIX_DETAILS:-}" ]]; then
local detail_join
detail_join=$(echo "${AUTO_FIX_DETAILS}" | paste -sd ", " -)
[[ -n "$detail_join" ]] && summary_line4+="${detail_join}"
fi
else else
summary_line4="Mac should feel faster and more responsive" summary_title="Optimization and Check Complete"
fi summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations; all system services tuned")
summary_details+=("$summary_line4") summary_details+=("Updates, security and system health fully reviewed")
if [[ -n "${AUTO_FIX_SUMMARY:-}" ]]; then local summary_line4=""
summary_details+=("$AUTO_FIX_SUMMARY") if [[ -n "${AUTO_FIX_SUMMARY:-}" ]]; then
summary_line4="${AUTO_FIX_SUMMARY}"
if [[ -n "${AUTO_FIX_DETAILS:-}" ]]; then
local detail_join
detail_join=$(echo "${AUTO_FIX_DETAILS}" | paste -sd ", " -)
[[ -n "$detail_join" ]] && summary_line4+="${detail_join}"
fi
else
summary_line4="Your Mac is now faster and more responsive"
fi
summary_details+=("$summary_line4")
fi fi
# Fix: Ensure summary is always printed for optimizations
print_summary_block "$summary_title" "${summary_details[@]}" print_summary_block "$summary_title" "${summary_details[@]}"
} }
show_system_health() { show_system_health() {
local health_json="$1" local health_json="$1"
# Parse system health using jq with fallback to 0
local mem_used=$(echo "$health_json" | jq -r '.memory_used_gb // 0' 2> /dev/null || echo "0") local mem_used=$(echo "$health_json" | jq -r '.memory_used_gb // 0' 2> /dev/null || echo "0")
local mem_total=$(echo "$health_json" | jq -r '.memory_total_gb // 0' 2> /dev/null || echo "0") local mem_total=$(echo "$health_json" | jq -r '.memory_total_gb // 0' 2> /dev/null || echo "0")
local disk_used=$(echo "$health_json" | jq -r '.disk_used_gb // 0' 2> /dev/null || echo "0") local disk_used=$(echo "$health_json" | jq -r '.disk_used_gb // 0' 2> /dev/null || echo "0")
@@ -118,7 +113,6 @@ show_system_health() {
local disk_percent=$(echo "$health_json" | jq -r '.disk_used_percent // 0' 2> /dev/null || echo "0") local disk_percent=$(echo "$health_json" | jq -r '.disk_used_percent // 0' 2> /dev/null || echo "0")
local uptime=$(echo "$health_json" | jq -r '.uptime_days // 0' 2> /dev/null || echo "0") local uptime=$(echo "$health_json" | jq -r '.uptime_days // 0' 2> /dev/null || echo "0")
# Ensure all values are numeric (fallback to 0)
mem_used=${mem_used:-0} mem_used=${mem_used:-0}
mem_total=${mem_total:-0} mem_total=${mem_total:-0}
disk_used=${disk_used:-0} disk_used=${disk_used:-0}
@@ -126,15 +120,12 @@ show_system_health() {
disk_percent=${disk_percent:-0} disk_percent=${disk_percent:-0}
uptime=${uptime:-0} uptime=${uptime:-0}
# Compact one-line format with icon
printf "${ICON_ADMIN} System %.0f/%.0f GB RAM | %.0f/%.0f GB Disk | Uptime %.0fd\n" \ printf "${ICON_ADMIN} System %.0f/%.0f GB RAM | %.0f/%.0f GB Disk | Uptime %.0fd\n" \
"$mem_used" "$mem_total" "$disk_used" "$disk_total" "$uptime" "$mem_used" "$mem_total" "$disk_used" "$disk_total" "$uptime"
} }
parse_optimizations() { parse_optimizations() {
local health_json="$1" local health_json="$1"
# Extract optimizations array
echo "$health_json" | jq -c '.optimizations[]' 2> /dev/null echo "$health_json" | jq -c '.optimizations[]' 2> /dev/null
} }
@@ -143,23 +134,12 @@ announce_action() {
local desc="$2" local desc="$2"
local kind="$3" local kind="$3"
local badge="" if [[ "${FIRST_ACTION:-true}" == "true" ]]; then
if [[ "$kind" == "confirm" ]]; then export FIRST_ACTION=false
badge="${YELLOW}[Confirm]${NC} "
fi
local line="${BLUE}${ICON_ARROW}${NC} ${badge}${name}"
if [[ -n "$desc" ]]; then
line+=" ${GRAY}- ${desc}${NC}"
fi
if ${first_heading:-true}; then
first_heading=false
else else
echo "" echo ""
fi fi
echo -e "${BLUE}${ICON_ARROW} ${name}${NC}"
echo -e "$line"
} }
touchid_configured() { touchid_configured() {
@@ -169,9 +149,16 @@ touchid_configured() {
touchid_supported() { touchid_supported() {
if command -v bioutil > /dev/null 2>&1; then if command -v bioutil > /dev/null 2>&1; then
bioutil -r 2> /dev/null | grep -q "Touch ID" && return 0 if bioutil -r 2> /dev/null | grep -qi "Touch ID"; then
return 0
fi
fi fi
[[ "$(uname -m)" == "arm64" ]]
# Fallback: Apple Silicon Macs usually have Touch ID.
if [[ "$(uname -m)" == "arm64" ]]; then
return 0
fi
return 1
} }
cleanup_path() { cleanup_path() {
@@ -183,6 +170,10 @@ cleanup_path() {
echo -e "${GREEN}${ICON_SUCCESS}${NC} $label" echo -e "${GREEN}${ICON_SUCCESS}${NC} $label"
return return
fi fi
if should_protect_path "$expanded_path"; then
echo -e "${YELLOW}${ICON_WARNING}${NC} Protected $label"
return
fi
local size_kb local size_kb
size_kb=$(get_path_size_kb "$expanded_path") size_kb=$(get_path_size_kb "$expanded_path")
@@ -214,23 +205,7 @@ cleanup_path() {
ensure_directory() { ensure_directory() {
local raw_path="$1" local raw_path="$1"
local expanded_path="${raw_path/#\~/$HOME}" local expanded_path="${raw_path/#\~/$HOME}"
mkdir -p "$expanded_path" > /dev/null 2>&1 || true ensure_user_dir "$expanded_path"
}
count_local_snapshots() {
if ! command -v tmutil > /dev/null 2>&1; then
echo 0
return
fi
local output
output=$(tmutil listlocalsnapshots / 2> /dev/null || true)
if [[ -z "$output" ]]; then
echo 0
return
fi
echo "$output" | grep -c "com.apple.TimeMachine." | tr -d ' '
} }
declare -a SECURITY_FIXES=() declare -a SECURITY_FIXES=()
@@ -248,7 +223,7 @@ collect_security_fix_actions() {
fi fi
fi fi
if touchid_supported && ! touchid_configured; then if touchid_supported && ! touchid_configured; then
if ! is_whitelisted "touchid"; then if ! is_whitelisted "check_touchid"; then
SECURITY_FIXES+=("touchid|Enable Touch ID for sudo") SECURITY_FIXES+=("touchid|Enable Touch ID for sudo")
fi fi
fi fi
@@ -261,35 +236,37 @@ ask_for_security_fixes() {
return 1 return 1
fi fi
echo ""
echo -e "${BLUE}SECURITY FIXES${NC}" echo -e "${BLUE}SECURITY FIXES${NC}"
for entry in "${SECURITY_FIXES[@]}"; do for entry in "${SECURITY_FIXES[@]}"; do
IFS='|' read -r _ label <<< "$entry" IFS='|' read -r _ label <<< "$entry"
echo -e " ${ICON_LIST} $label" echo -e " ${ICON_LIST} $label"
done done
echo "" echo ""
echo -ne "${YELLOW}Apply now?${NC} ${GRAY}Enter confirm / ESC cancel${NC}: " export MOLE_SECURITY_FIXES_SHOWN=true
echo -ne "${YELLOW}Apply now?${NC} ${GRAY}Enter confirm / Space cancel${NC}: "
local key local key
if ! key=$(read_key); then if ! key=$(read_key); then
echo "skip" export MOLE_SECURITY_FIXES_SKIPPED=true
echo -e "\n ${GRAY}${ICON_WARNING}${NC} Security fixes skipped"
echo "" echo ""
return 1 return 1
fi fi
if [[ "$key" == "ENTER" ]]; then if [[ "$key" == "ENTER" ]]; then
echo "apply"
echo "" echo ""
return 0 return 0
else else
echo "skip" export MOLE_SECURITY_FIXES_SKIPPED=true
echo -e "\n ${GRAY}${ICON_WARNING}${NC} Security fixes skipped"
echo "" echo ""
return 1 return 1
fi fi
} }
apply_firewall_fix() { apply_firewall_fix() {
if sudo defaults write /Library/Preferences/com.apple.alf globalstate -int 1; then if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then
sudo pkill -HUP socketfilterfw 2> /dev/null || true
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Firewall enabled" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Firewall enabled"
FIREWALL_DISABLED=false FIREWALL_DISABLED=false
return 0 return 0
@@ -344,18 +321,26 @@ perform_security_fixes() {
} }
cleanup_all() { cleanup_all() {
stop_inline_spinner 2> /dev/null || true
stop_sudo_session stop_sudo_session
cleanup_temp_files cleanup_temp_files
} }
handle_interrupt() {
cleanup_all
exit 130
}
main() { main() {
local health_json # Declare health_json at the top of main scope local health_json
# Parse args
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
"--debug") "--debug")
export MO_DEBUG=1 export MO_DEBUG=1
;; ;;
"--dry-run")
export MOLE_DRY_RUN=1
;;
"--whitelist") "--whitelist")
manage_whitelist "optimize" manage_whitelist "optimize"
exit 0 exit 0
@@ -363,28 +348,31 @@ main() {
esac esac
done done
# Register unified cleanup handler trap cleanup_all EXIT
trap cleanup_all EXIT INT TERM trap handle_interrupt INT TERM
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
clear clear
fi fi
print_header # Outputs "Optimize and Check" print_header
# Dry-run indicator.
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC} - No files will be modified\n"
fi
# Check dependencies
if ! command -v jq > /dev/null 2>&1; then if ! command -v jq > /dev/null 2>&1; then
echo -e "${RED}${ICON_ERROR}${NC} Missing dependency: jq" echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: jq"
echo -e "${GRAY}Install with: ${GREEN}brew install jq${NC}" echo -e "${GRAY}Install with: ${GREEN}brew install jq${NC}"
exit 1 exit 1
fi fi
if ! command -v bc > /dev/null 2>&1; then if ! command -v bc > /dev/null 2>&1; then
echo -e "${RED}${ICON_ERROR}${NC} Missing dependency: bc" echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: bc"
echo -e "${GRAY}Install with: ${GREEN}brew install bc${NC}" echo -e "${GRAY}Install with: ${GREEN}brew install bc${NC}"
exit 1 exit 1
fi fi
# Collect system health data (doesn't require sudo)
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
start_inline_spinner "Collecting system info..." start_inline_spinner "Collecting system info..."
fi fi
@@ -398,7 +386,6 @@ main() {
exit 1 exit 1
fi fi
# Validate JSON before proceeding
if ! echo "$health_json" | jq empty 2> /dev/null; then if ! echo "$health_json" | jq empty 2> /dev/null; then
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
stop_inline_spinner stop_inline_spinner
@@ -413,13 +400,9 @@ main() {
stop_inline_spinner stop_inline_spinner
fi fi
# Show system health show_system_health "$health_json"
show_system_health "$health_json" # Outputs "⚙ System ..."
# Load whitelist patterns for checks
load_whitelist "optimize" load_whitelist "optimize"
# Display active whitelist patterns
if [[ ${#CURRENT_WHITELIST_PATTERNS[@]} -gt 0 ]]; then if [[ ${#CURRENT_WHITELIST_PATTERNS[@]} -gt 0 ]]; then
local count=${#CURRENT_WHITELIST_PATTERNS[@]} local count=${#CURRENT_WHITELIST_PATTERNS[@]}
if [[ $count -le 3 ]]; then if [[ $count -le 3 ]]; then
@@ -428,37 +411,11 @@ main() {
echo "${CURRENT_WHITELIST_PATTERNS[*]}" echo "${CURRENT_WHITELIST_PATTERNS[*]}"
) )
echo -e "${ICON_ADMIN} Active Whitelist: ${patterns_list}" echo -e "${ICON_ADMIN} Active Whitelist: ${patterns_list}"
else
echo -e "${ICON_ADMIN} Active Whitelist: ${GRAY}${count} items${NC}"
fi fi
fi fi
echo "" # Empty line before sudo prompt
# Simple confirmation
echo -ne "${PURPLE}${ICON_ARROW}${NC} Optimization needs sudo — ${GREEN}Enter${NC} continue, ${GRAY}ESC${NC} cancel: "
local key
if ! key=$(read_key); then
echo -e " ${GRAY}Cancelled${NC}"
exit 0
fi
if [[ "$key" == "ENTER" ]]; then
printf "\r\033[K"
else
echo -e " ${GRAY}Cancelled${NC}"
exit 0
fi
if [[ -t 1 ]]; then
stop_inline_spinner
fi
# Parse and display optimizations
local -a safe_items=() local -a safe_items=()
local -a confirm_items=() local -a confirm_items=()
# Use temp file instead of process substitution to avoid hanging
local opts_file local opts_file
opts_file=$(mktemp_file) opts_file=$(mktemp_file)
parse_optimizations "$health_json" > "$opts_file" parse_optimizations "$health_json" > "$opts_file"
@@ -481,12 +438,12 @@ main() {
fi fi
done < "$opts_file" done < "$opts_file"
# Execute all optimizations echo ""
local first_heading=true if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
ensure_sudo_session "System optimization requires admin access" || true
fi
ensure_sudo_session "System optimization requires admin access" || true export FIRST_ACTION=true
# Run safe optimizations
if [[ ${#safe_items[@]} -gt 0 ]]; then if [[ ${#safe_items[@]} -gt 0 ]]; then
for item in "${safe_items[@]}"; do for item in "${safe_items[@]}"; do
IFS='|' read -r name desc action path <<< "$item" IFS='|' read -r name desc action path <<< "$item"
@@ -495,7 +452,6 @@ main() {
done done
fi fi
# Run confirm items
if [[ ${#confirm_items[@]} -gt 0 ]]; then if [[ ${#confirm_items[@]} -gt 0 ]]; then
for item in "${confirm_items[@]}"; do for item in "${confirm_items[@]}"; do
IFS='|' read -r name desc action path <<< "$item" IFS='|' read -r name desc action path <<< "$item"
@@ -504,17 +460,14 @@ main() {
done done
fi fi
# Prepare optimization summary data (to show at the end)
local safe_count=${#safe_items[@]} local safe_count=${#safe_items[@]}
local confirm_count=${#confirm_items[@]} local confirm_count=${#confirm_items[@]}
# Run system checks first
run_system_checks run_system_checks
export OPTIMIZE_SAFE_COUNT=$safe_count export OPTIMIZE_SAFE_COUNT=$safe_count
export OPTIMIZE_CONFIRM_COUNT=$confirm_count export OPTIMIZE_CONFIRM_COUNT=$confirm_count
# Show optimization summary at the end
show_optimization_summary show_optimization_summary
printf '\n' printf '\n'

166
bin/purge.sh Executable file
View File

@@ -0,0 +1,166 @@
#!/bin/bash
# Mole - Purge command.
# Cleans heavy project build artifacts.
# Interactive selection by project.
set -euo pipefail
# Fix locale issues (avoid Perl warnings on non-English systems)
export LC_ALL=C
export LANG=C
# Get script directory and source common functions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../lib/core/common.sh"
# Set up cleanup trap for temporary files
trap cleanup_temp_files EXIT INT TERM
source "$SCRIPT_DIR/../lib/core/log.sh"
source "$SCRIPT_DIR/../lib/clean/project.sh"
# Configuration
CURRENT_SECTION=""
# Section management
start_section() {
local section_name="$1"
CURRENT_SECTION="$section_name"
printf '\n'
echo -e "${BLUE}━━━ ${section_name} ━━━${NC}"
}
end_section() {
CURRENT_SECTION=""
}
# Note activity for export list
note_activity() {
if [[ -n "$CURRENT_SECTION" ]]; then
printf '%s\n' "$CURRENT_SECTION" >> "$EXPORT_LIST_FILE"
fi
}
# Main purge function
start_purge() {
# Clear screen for better UX
if [[ -t 1 ]]; then
printf '\033[2J\033[H'
fi
printf '\n'
echo -e "${PURPLE_BOLD}Purge Project Artifacts${NC}"
# Initialize stats file in user cache directory
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
ensure_user_dir "$stats_dir"
ensure_user_file "$stats_dir/purge_stats"
ensure_user_file "$stats_dir/purge_count"
echo "0" > "$stats_dir/purge_stats"
echo "0" > "$stats_dir/purge_count"
}
# Perform the purge
perform_purge() {
clean_project_artifacts
local exit_code=$?
# Exit codes:
# 0 = success, show summary
# 1 = user cancelled
# 2 = nothing to clean
if [[ $exit_code -ne 0 ]]; then
return 0
fi
# Final summary (matching clean.sh format)
echo ""
local summary_heading="Purge complete"
local -a summary_details=()
local total_size_cleaned=0
local total_items_cleaned=0
# Read stats from user cache directory
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
if [[ -f "$stats_dir/purge_stats" ]]; then
total_size_cleaned=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0")
rm -f "$stats_dir/purge_stats"
fi
# Read count
if [[ -f "$stats_dir/purge_count" ]]; then
total_items_cleaned=$(cat "$stats_dir/purge_count" 2> /dev/null || echo "0")
rm -f "$stats_dir/purge_count"
fi
if [[ $total_size_cleaned -gt 0 ]]; then
local freed_gb
freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}')
summary_details+=("Space freed: ${GREEN}${freed_gb}GB${NC}")
summary_details+=("Free space now: $(get_free_space)")
if [[ $total_items_cleaned -gt 0 ]]; then
summary_details+=("Items cleaned: $total_items_cleaned")
fi
else
summary_details+=("No old project artifacts to clean.")
summary_details+=("Free space now: $(get_free_space)")
fi
print_summary_block "$summary_heading" "${summary_details[@]}"
printf '\n'
}
# Show help message
show_help() {
echo -e "${PURPLE_BOLD}Mole Purge${NC} - Clean old project build artifacts"
echo ""
echo -e "${YELLOW}Usage:${NC} mo purge [options]"
echo ""
echo -e "${YELLOW}Options:${NC}"
echo " --paths Edit custom scan directories"
echo " --debug Enable debug logging"
echo " --help Show this help message"
echo ""
echo -e "${YELLOW}Default Paths:${NC}"
for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do
echo " - $path"
done
}
# Main entry point
main() {
# Set up signal handling
trap 'show_cursor; exit 130' INT TERM
# Parse arguments
for arg in "$@"; do
case "$arg" in
"--paths")
source "$SCRIPT_DIR/../lib/manage/purge_paths.sh"
manage_purge_paths
exit 0
;;
"--help")
show_help
exit 0
;;
"--debug")
export MO_DEBUG=1
;;
*)
echo "Unknown option: $arg"
echo "Use 'mo purge --help' for usage information"
exit 1
;;
esac
done
start_purge
hide_cursor
perform_purge
show_cursor
}
main "$@"

Binary file not shown.

View File

@@ -1,5 +1,7 @@
#!/bin/bash #!/bin/bash
# Entry point for the Go-based system status panel bundled with Mole. # Mole - Status command.
# Runs the Go system status panel.
# Shows live system metrics.
set -euo pipefail set -euo pipefail

View File

@@ -1,6 +1,7 @@
#!/bin/bash #!/bin/bash
# Mole - Touch ID Configuration Helper # Mole - Touch ID command.
# Automatically configure Touch ID for sudo # Configures sudo with Touch ID.
# Guided toggle with safety checks.
set -euo pipefail set -euo pipefail
@@ -109,8 +110,7 @@ enable_touchid() {
# Apply the changes # Apply the changes
if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then
echo -e "${GREEN}${ICON_SUCCESS} Touch ID enabled${NC} ${GRAY}- try: sudo ls${NC}" log_success "Touch ID enabled - try: sudo ls"
echo ""
return 0 return 0
else else
log_error "Failed to enable Touch ID" log_error "Failed to enable Touch ID"

View File

@@ -1,166 +1,134 @@
#!/bin/bash #!/bin/bash
# Mole - Uninstall Module # Mole - Uninstall command.
# Interactive application uninstaller with keyboard navigation # Interactive app uninstaller.
# # Removes app files and leftovers.
# Usage:
# uninstall.sh # Launch interactive uninstaller
# uninstall.sh --force-rescan # Rescan apps and refresh cache
set -euo pipefail set -euo pipefail
# Fix locale issues (avoid Perl warnings on non-English systems) # Fix locale issues on non-English systems.
export LC_ALL=C export LC_ALL=C
export LANG=C export LANG=C
# Get script directory and source common functions # Load shared helpers.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../lib/core/common.sh" source "$SCRIPT_DIR/../lib/core/common.sh"
# Clean temp files on exit.
trap cleanup_temp_files EXIT INT TERM
source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh" source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh"
source "$SCRIPT_DIR/../lib/ui/app_selector.sh" source "$SCRIPT_DIR/../lib/ui/app_selector.sh"
source "$SCRIPT_DIR/../lib/uninstall/batch.sh" source "$SCRIPT_DIR/../lib/uninstall/batch.sh"
# Note: Bundle preservation logic is now in lib/core/common.sh # State
selected_apps=()
# Initialize global variables
selected_apps=() # Global array for app selection
declare -a apps_data=() declare -a apps_data=()
declare -a selection_state=() declare -a selection_state=()
total_items=0 total_items=0
files_cleaned=0 files_cleaned=0
total_size_cleaned=0 total_size_cleaned=0
# Compact the "last used" descriptor for aligned summaries # Scan applications and collect information.
format_last_used_summary() {
local value="$1"
case "$value" in
"" | "Unknown")
echo "Unknown"
return 0
;;
"Never" | "Recent" | "Today" | "Yesterday" | "This year" | "Old")
echo "$value"
return 0
;;
esac
if [[ $value =~ ^([0-9]+)[[:space:]]+days?\ ago$ ]]; then
echo "${BASH_REMATCH[1]}d ago"
return 0
fi
if [[ $value =~ ^([0-9]+)[[:space:]]+weeks?\ ago$ ]]; then
echo "${BASH_REMATCH[1]}w ago"
return 0
fi
if [[ $value =~ ^([0-9]+)[[:space:]]+months?\ ago$ ]]; then
echo "${BASH_REMATCH[1]}m ago"
return 0
fi
if [[ $value =~ ^([0-9]+)[[:space:]]+month\(s\)\ ago$ ]]; then
echo "${BASH_REMATCH[1]}m ago"
return 0
fi
if [[ $value =~ ^([0-9]+)[[:space:]]+years?\ ago$ ]]; then
echo "${BASH_REMATCH[1]}y ago"
return 0
fi
echo "$value"
}
# Scan applications and collect information
scan_applications() { scan_applications() {
# Simplified cache: only check timestamp (24h TTL) # Cache app scan (24h TTL).
local cache_dir="$HOME/.cache/mole" local cache_dir="$HOME/.cache/mole"
local cache_file="$cache_dir/app_scan_cache" local cache_file="$cache_dir/app_scan_cache"
local cache_ttl=86400 # 24 hours local cache_ttl=86400 # 24 hours
local force_rescan="${1:-false}" local force_rescan="${1:-false}"
mkdir -p "$cache_dir" 2> /dev/null ensure_user_dir "$cache_dir"
# Check if cache exists and is fresh
if [[ $force_rescan == false && -f "$cache_file" ]]; then if [[ $force_rescan == false && -f "$cache_file" ]]; then
local cache_age=$(($(date +%s) - $(get_file_mtime "$cache_file"))) local cache_age=$(($(date +%s) - $(get_file_mtime "$cache_file")))
[[ $cache_age -eq $(date +%s) ]] && cache_age=86401 # Handle missing file [[ $cache_age -eq $(date +%s) ]] && cache_age=86401 # Handle mtime read failure
if [[ $cache_age -lt $cache_ttl ]]; then if [[ $cache_age -lt $cache_ttl ]]; then
# Cache hit - return immediately
# Show brief flash of cache usage if in interactive mode
if [[ -t 2 ]]; then if [[ -t 2 ]]; then
echo -e "${GREEN}Loading from cache...${NC}" >&2 echo -e "${GREEN}Loading from cache...${NC}" >&2
# Small sleep to let user see it (optional, but good for "feeling" the speed vs glitch) sleep 0.3 # Brief pause so user sees the message
sleep 0.3
fi fi
echo "$cache_file" echo "$cache_file"
return 0 return 0
fi fi
fi fi
# Cache miss - prepare for scanning
local inline_loading=false local inline_loading=false
if [[ -t 1 && -t 2 ]]; then if [[ -t 1 && -t 2 ]]; then
inline_loading=true inline_loading=true
# Clear screen for inline loading printf "\033[2J\033[H" >&2 # Clear screen for inline loading
printf "\033[2J\033[H" >&2
fi fi
local temp_file local temp_file
temp_file=$(create_temp_file) temp_file=$(create_temp_file)
# Pre-cache current epoch to avoid repeated calls
local current_epoch local current_epoch
current_epoch=$(date "+%s") current_epoch=$(date "+%s")
# First pass: quickly collect all valid app paths and bundle IDs (NO mdls calls) # Pass 1: collect app paths and bundle IDs (no mdls).
local -a app_data_tuples=() local -a app_data_tuples=()
while IFS= read -r -d '' app_path; do local -a app_dirs=(
if [[ ! -e "$app_path" ]]; then continue; fi "/Applications"
"$HOME/Applications"
local app_name
app_name=$(basename "$app_path" .app)
# Skip nested apps (e.g. inside Wrapper/ or Frameworks/ of another app)
# Check if parent path component ends in .app (e.g. /Foo.app/Bar.app or /Foo.app/Contents/Bar.app)
# This prevents false positives like /Old.apps/Target.app
local parent_dir
parent_dir=$(dirname "$app_path")
if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then
continue
fi
# Get bundle ID only (fast, no mdls calls in first pass)
local bundle_id="unknown"
if [[ -f "$app_path/Contents/Info.plist" ]]; then
bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown")
fi
# Skip system critical apps (input methods, system components)
if should_protect_from_uninstall "$bundle_id"; then
continue
fi
# Store tuple: app_path|app_name|bundle_id (display_name will be resolved in parallel later)
app_data_tuples+=("${app_path}|${app_name}|${bundle_id}")
done < <(
# Scan both system and user application directories
# Using maxdepth 3 to find apps in subdirectories (e.g., Adobe apps in /Applications/Adobe X/)
command find /Applications -name "*.app" -maxdepth 3 -print0 2> /dev/null
command find ~/Applications -name "*.app" -maxdepth 3 -print0 2> /dev/null
) )
local vol_app_dir
local nullglob_was_set=0
shopt -q nullglob && nullglob_was_set=1
shopt -s nullglob
for vol_app_dir in /Volumes/*/Applications; do
[[ -d "$vol_app_dir" && -r "$vol_app_dir" ]] || continue
if [[ -d "/Applications" && "$vol_app_dir" -ef "/Applications" ]]; then
continue
fi
if [[ -d "$HOME/Applications" && "$vol_app_dir" -ef "$HOME/Applications" ]]; then
continue
fi
app_dirs+=("$vol_app_dir")
done
if [[ $nullglob_was_set -eq 0 ]]; then
shopt -u nullglob
fi
# Second pass: process each app with parallel size calculation for app_dir in "${app_dirs[@]}"; do
if [[ ! -d "$app_dir" ]]; then continue; fi
while IFS= read -r -d '' app_path; do
if [[ ! -e "$app_path" ]]; then continue; fi
local app_name
app_name=$(basename "$app_path" .app)
# Skip nested apps inside another .app bundle.
local parent_dir
parent_dir=$(dirname "$app_path")
if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then
continue
fi
# Bundle ID from plist (fast path).
local bundle_id="unknown"
if [[ -f "$app_path/Contents/Info.plist" ]]; then
bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown")
fi
if should_protect_from_uninstall "$bundle_id"; then
continue
fi
# Store tuple for pass 2 (metadata + size).
app_data_tuples+=("${app_path}|${app_name}|${bundle_id}")
done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null)
done
# Pass 2: metadata + size in parallel (mdls is slow).
local app_count=0 local app_count=0
local total_apps=${#app_data_tuples[@]} local total_apps=${#app_data_tuples[@]}
# Bound parallelism - for metadata queries, can go higher since it's mostly waiting
local max_parallel local max_parallel
max_parallel=$(get_optimal_parallel_jobs "io") max_parallel=$(get_optimal_parallel_jobs "io")
if [[ $max_parallel -lt 8 ]]; then if [[ $max_parallel -lt 8 ]]; then
max_parallel=8 max_parallel=8 # At least 8 for good performance
elif [[ $max_parallel -gt 32 ]]; then elif [[ $max_parallel -gt 32 ]]; then
max_parallel=32 max_parallel=32 # Cap at 32 to avoid too many processes
fi fi
local pids=() local pids=()
# inline_loading variable already set above (line ~92)
# Process app metadata extraction function
process_app_metadata() { process_app_metadata() {
local app_data_tuple="$1" local app_data_tuple="$1"
local output_file="$2" local output_file="$2"
@@ -168,24 +136,26 @@ scan_applications() {
IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple" IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple"
# Get localized display name (moved from first pass for better performance) # Display name priority: mdls display name → bundle display → bundle name → folder.
local display_name="$app_name" local display_name="$app_name"
if [[ -f "$app_path/Contents/Info.plist" ]]; then if [[ -f "$app_path/Contents/Info.plist" ]]; then
# Try to get localized name from system metadata (best for i18n)
local md_display_name local md_display_name
md_display_name=$(run_with_timeout 0.05 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") md_display_name=$(run_with_timeout 0.05 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "")
# Get bundle names
local bundle_display_name local bundle_display_name
bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null) bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null)
local bundle_name local bundle_name
bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null) bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null)
# Priority order for name selection (prefer localized names): if [[ "$md_display_name" == /* ]]; then md_display_name=""; fi
# 1. System metadata display name (kMDItemDisplayName) - respects system language md_display_name="${md_display_name//|/-}"
# 2. CFBundleDisplayName - usually localized md_display_name="${md_display_name//[$'\t\r\n']/}"
# 3. CFBundleName - fallback
# 4. App folder name - last resort bundle_display_name="${bundle_display_name//|/-}"
bundle_display_name="${bundle_display_name//[$'\t\r\n']/}"
bundle_name="${bundle_name//|/-}"
bundle_name="${bundle_name//[$'\t\r\n']/}"
if [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then if [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then
display_name="$md_display_name" display_name="$md_display_name"
@@ -196,29 +166,32 @@ scan_applications() {
fi fi
fi fi
# Parallel size calculation if [[ "$display_name" == /* ]]; then
display_name="$app_name"
fi
display_name="${display_name//|/-}"
display_name="${display_name//[$'\t\r\n']/}"
# App size (KB → human).
local app_size="N/A" local app_size="N/A"
local app_size_kb="0" local app_size_kb="0"
if [[ -d "$app_path" ]]; then if [[ -d "$app_path" ]]; then
# Get size in KB, then format for display
app_size_kb=$(get_path_size_kb "$app_path") app_size_kb=$(get_path_size_kb "$app_path")
app_size=$(bytes_to_human "$((app_size_kb * 1024))") app_size=$(bytes_to_human "$((app_size_kb * 1024))")
fi fi
# Get last used date # Last used: mdls (fast timeout) → mtime.
local last_used="Never" local last_used="Never"
local last_used_epoch=0 local last_used_epoch=0
if [[ -d "$app_path" ]]; then if [[ -d "$app_path" ]]; then
# Try mdls first with short timeout (0.05s) for accuracy, fallback to mtime for speed
local metadata_date local metadata_date
metadata_date=$(run_with_timeout 0.05 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") metadata_date=$(run_with_timeout 0.1 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "")
if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then
last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0")
fi fi
# Fallback if mdls failed or returned nothing
if [[ "$last_used_epoch" -eq 0 ]]; then if [[ "$last_used_epoch" -eq 0 ]]; then
last_used_epoch=$(get_file_mtime "$app_path") last_used_epoch=$(get_file_mtime "$app_path")
fi fi
@@ -245,21 +218,19 @@ scan_applications() {
fi fi
fi fi
# Write to output file atomically
# Fields: epoch|app_path|display_name|bundle_id|size_human|last_used|size_kb
echo "${last_used_epoch}|${app_path}|${display_name}|${bundle_id}|${app_size}|${last_used}|${app_size_kb}" >> "$output_file" echo "${last_used_epoch}|${app_path}|${display_name}|${bundle_id}|${app_size}|${last_used}|${app_size_kb}" >> "$output_file"
} }
export -f process_app_metadata export -f process_app_metadata
# Create a temporary file to track progress
local progress_file="${temp_file}.progress" local progress_file="${temp_file}.progress"
echo "0" > "$progress_file" echo "0" > "$progress_file"
# Start a background spinner that reads progress from file
local spinner_pid="" local spinner_pid=""
( (
trap 'exit 0' TERM INT EXIT # shellcheck disable=SC2329 # Function invoked indirectly via trap
cleanup_spinner() { exit 0; }
trap cleanup_spinner TERM INT EXIT
local spinner_chars="|/-\\" local spinner_chars="|/-\\"
local i=0 local i=0
while true; do while true; do
@@ -276,30 +247,22 @@ scan_applications() {
) & ) &
spinner_pid=$! spinner_pid=$!
# Process apps in parallel batches
for app_data_tuple in "${app_data_tuples[@]}"; do for app_data_tuple in "${app_data_tuples[@]}"; do
((app_count++)) ((app_count++))
# Launch background process
process_app_metadata "$app_data_tuple" "$temp_file" "$current_epoch" & process_app_metadata "$app_data_tuple" "$temp_file" "$current_epoch" &
pids+=($!) pids+=($!)
# Update progress to show scanning progress (use app_count as it increments smoothly)
echo "$app_count" > "$progress_file" echo "$app_count" > "$progress_file"
# Wait if we've hit max parallel limit
if ((${#pids[@]} >= max_parallel)); then if ((${#pids[@]} >= max_parallel)); then
wait "${pids[0]}" 2> /dev/null wait "${pids[0]}" 2> /dev/null
pids=("${pids[@]:1}") # Remove first pid pids=("${pids[@]:1}")
fi fi
done done
# Wait for remaining background processes
for pid in "${pids[@]}"; do for pid in "${pids[@]}"; do
wait "$pid" 2> /dev/null wait "$pid" 2> /dev/null
done done
# Stop the spinner and clear the line
if [[ -n "$spinner_pid" ]]; then if [[ -n "$spinner_pid" ]]; then
kill -TERM "$spinner_pid" 2> /dev/null || true kill -TERM "$spinner_pid" 2> /dev/null || true
wait "$spinner_pid" 2> /dev/null || true wait "$spinner_pid" 2> /dev/null || true
@@ -311,15 +274,12 @@ scan_applications() {
fi fi
rm -f "$progress_file" rm -f "$progress_file"
# Check if we found any applications
if [[ ! -s "$temp_file" ]]; then if [[ ! -s "$temp_file" ]]; then
echo "No applications found to uninstall" >&2 echo "No applications found to uninstall" >&2
rm -f "$temp_file" rm -f "$temp_file"
return 1 return 1
fi fi
# Sort by last used (oldest first) and cache the result
# Show brief processing message for large app lists
if [[ $total_apps -gt 50 ]]; then if [[ $total_apps -gt 50 ]]; then
if [[ $inline_loading == true ]]; then if [[ $inline_loading == true ]]; then
printf "\033[H\033[2KProcessing %d applications...\n" "$total_apps" >&2 printf "\033[H\033[2KProcessing %d applications...\n" "$total_apps" >&2
@@ -334,7 +294,6 @@ scan_applications() {
} }
rm -f "$temp_file" rm -f "$temp_file"
# Clear processing message
if [[ $total_apps -gt 50 ]]; then if [[ $total_apps -gt 50 ]]; then
if [[ $inline_loading == true ]]; then if [[ $inline_loading == true ]]; then
printf "\033[H\033[2K" >&2 printf "\033[H\033[2K" >&2
@@ -343,10 +302,9 @@ scan_applications() {
fi fi
fi fi
# Save to cache (simplified - no metadata) ensure_user_file "$cache_file"
cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true
# Return sorted file
if [[ -f "${temp_file}.sorted" ]]; then if [[ -f "${temp_file}.sorted" ]]; then
echo "${temp_file}.sorted" echo "${temp_file}.sorted"
else else
@@ -354,7 +312,6 @@ scan_applications() {
fi fi
} }
# Load applications into arrays
load_applications() { load_applications() {
local apps_file="$1" local apps_file="$1"
@@ -363,13 +320,10 @@ load_applications() {
return 1 return 1
fi fi
# Clear arrays
apps_data=() apps_data=()
selection_state=() selection_state=()
# Read apps into array, skip non-existent apps
while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do
# Skip if app path no longer exists
[[ ! -e "$app_path" ]] && continue [[ ! -e "$app_path" ]] && continue
apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}") apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}")
@@ -384,9 +338,8 @@ load_applications() {
return 0 return 0
} }
# Cleanup function - restore cursor and clean up # Cleanup: restore cursor and kill keepalive.
cleanup() { cleanup() {
# Restore cursor using common function
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE unset MOLE_ALT_SCREEN_ACTIVE
@@ -400,21 +353,16 @@ cleanup() {
exit "${1:-0}" exit "${1:-0}"
} }
# Set trap for cleanup on exit
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
# Main function
main() { main() {
# Parse args
local force_rescan=false local force_rescan=false
# Global flags
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
"--debug") "--debug")
export MO_DEBUG=1 export MO_DEBUG=1
;; ;;
"--force-rescan")
force_rescan=true
;;
esac esac
done done
@@ -423,24 +371,18 @@ main() {
use_inline_loading=true use_inline_loading=true
fi fi
# Hide cursor during operation
hide_cursor hide_cursor
# Main interaction loop
while true; do while true; do
# Simplified: always check if we need alt screen for scanning
# (scan_applications handles cache internally)
local needs_scanning=true local needs_scanning=true
local cache_file="$HOME/.cache/mole/app_scan_cache" local cache_file="$HOME/.cache/mole/app_scan_cache"
if [[ $force_rescan == false && -f "$cache_file" ]]; then if [[ $force_rescan == false && -f "$cache_file" ]]; then
local cache_age=$(($(date +%s) - $(get_file_mtime "$cache_file"))) local cache_age=$(($(date +%s) - $(get_file_mtime "$cache_file")))
[[ $cache_age -eq $(date +%s) ]] && cache_age=86401 # Handle missing file [[ $cache_age -eq $(date +%s) ]] && cache_age=86401
[[ $cache_age -lt 86400 ]] && needs_scanning=false [[ $cache_age -lt 86400 ]] && needs_scanning=false
fi fi
# Only enter alt screen if we need scanning (shows progress)
if [[ $needs_scanning == true && $use_inline_loading == true ]]; then if [[ $needs_scanning == true && $use_inline_loading == true ]]; then
# Only enter if not already active
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" != "1" ]]; then if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" != "1" ]]; then
enter_alt_screen enter_alt_screen
export MOLE_ALT_SCREEN_ACTIVE=1 export MOLE_ALT_SCREEN_ACTIVE=1
@@ -449,10 +391,6 @@ main() {
fi fi
printf "\033[2J\033[H" >&2 printf "\033[2J\033[H" >&2
else else
# If we don't need scanning but have alt screen from previous iteration, keep it?
# Actually, scan_applications might output to stderr.
# Let's just unset the flags if we don't need scanning, but keep alt screen if it was active?
# No, select_apps_for_uninstall will handle its own screen management.
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN MOLE_ALT_SCREEN_ACTIVE unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN MOLE_ALT_SCREEN_ACTIVE
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen leave_alt_screen
@@ -460,7 +398,6 @@ main() {
fi fi
fi fi
# Scan applications
local apps_file="" local apps_file=""
if ! apps_file=$(scan_applications "$force_rescan"); then if ! apps_file=$(scan_applications "$force_rescan"); then
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
@@ -477,7 +414,6 @@ main() {
fi fi
if [[ ! -f "$apps_file" ]]; then if [[ ! -f "$apps_file" ]]; then
# Error message already shown by scan_applications
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE unset MOLE_ALT_SCREEN_ACTIVE
@@ -486,7 +422,6 @@ main() {
return 1 return 1
fi fi
# Load applications
if ! load_applications "$apps_file"; then if ! load_applications "$apps_file"; then
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen leave_alt_screen
@@ -497,7 +432,6 @@ main() {
return 1 return 1
fi fi
# Interactive selection using paginated menu
set +e set +e
select_apps_for_uninstall select_apps_for_uninstall
local exit_code=$? local exit_code=$?
@@ -511,63 +445,83 @@ main() {
fi fi
show_cursor show_cursor
clear_screen clear_screen
printf '\033[2J\033[H' >&2 # Also clear stderr printf '\033[2J\033[H' >&2
rm -f "$apps_file" rm -f "$apps_file"
# Handle Refresh (code 10)
if [[ $exit_code -eq 10 ]]; then if [[ $exit_code -eq 10 ]]; then
force_rescan=true force_rescan=true
continue continue
fi fi
# User cancelled selection, exit the loop
return 0 return 0
fi fi
# Always clear on exit from selection, regardless of alt screen state
if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then
leave_alt_screen leave_alt_screen
unset MOLE_ALT_SCREEN_ACTIVE unset MOLE_ALT_SCREEN_ACTIVE
unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN
fi fi
# Restore cursor and clear screen (output to both stdout and stderr for reliability)
show_cursor show_cursor
clear_screen clear_screen
printf '\033[2J\033[H' >&2 # Also clear stderr in case of mixed output printf '\033[2J\033[H' >&2
local selection_count=${#selected_apps[@]} local selection_count=${#selected_apps[@]}
if [[ $selection_count -eq 0 ]]; then if [[ $selection_count -eq 0 ]]; then
echo "No apps selected" echo "No apps selected"
rm -f "$apps_file" rm -f "$apps_file"
# Loop back or exit? If select_apps_for_uninstall returns 0 but empty selection,
# it technically shouldn't happen based on that function's logic.
continue continue
fi fi
# Show selected apps with clean alignment
echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} app(s):" echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} app(s):"
local -a summary_rows=() local -a summary_rows=()
local max_name_display_width=0 local max_name_display_width=0
local max_size_width=0 local max_size_width=0
local name_trunc_limit=30 local max_last_width=0
for selected_app in "${selected_apps[@]}"; do
IFS='|' read -r _ _ app_name _ size last_used _ <<< "$selected_app"
local name_width=$(get_display_width "$app_name")
[[ $name_width -gt $max_name_display_width ]] && max_name_display_width=$name_width
local size_display="$size"
[[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]] && size_display="Unknown"
[[ ${#size_display} -gt $max_size_width ]] && max_size_width=${#size_display}
local last_display=$(format_last_used_summary "$last_used")
[[ ${#last_display} -gt $max_last_width ]] && max_last_width=${#last_display}
done
((max_size_width < 5)) && max_size_width=5
((max_last_width < 5)) && max_last_width=5
local term_width=$(tput cols 2> /dev/null || echo 100)
local available_for_name=$((term_width - 17 - max_size_width - max_last_width))
local min_name_width=24
if [[ $term_width -ge 120 ]]; then
min_name_width=50
elif [[ $term_width -ge 100 ]]; then
min_name_width=42
elif [[ $term_width -ge 80 ]]; then
min_name_width=30
fi
local name_trunc_limit=$max_name_display_width
[[ $name_trunc_limit -lt $min_name_width ]] && name_trunc_limit=$min_name_width
[[ $name_trunc_limit -gt $available_for_name ]] && name_trunc_limit=$available_for_name
[[ $name_trunc_limit -gt 60 ]] && name_trunc_limit=60
max_name_display_width=0
for selected_app in "${selected_apps[@]}"; do for selected_app in "${selected_apps[@]}"; do
IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$selected_app" IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$selected_app"
# Truncate by display width if needed
local display_name local display_name
display_name=$(truncate_by_display_width "$app_name" "$name_trunc_limit") display_name=$(truncate_by_display_width "$app_name" "$name_trunc_limit")
# Get actual display width
local current_width local current_width
current_width=$(get_display_width "$display_name") current_width=$(get_display_width "$display_name")
[[ $current_width -gt $max_name_display_width ]] && max_name_display_width=$current_width [[ $current_width -gt $max_name_display_width ]] && max_name_display_width=$current_width
local size_display="$size" local size_display="$size"
if [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]]; then if [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]]; then
size_display="Unknown" size_display="Unknown"
fi fi
[[ ${#size_display} -gt $max_size_width ]] && max_size_width=${#size_display}
local last_display local last_display
last_display=$(format_last_used_summary "$last_used") last_display=$(format_last_used_summary "$last_used")
@@ -576,12 +530,10 @@ main() {
done done
((max_name_display_width < 16)) && max_name_display_width=16 ((max_name_display_width < 16)) && max_name_display_width=16
((max_size_width < 5)) && max_size_width=5
local index=1 local index=1
for row in "${summary_rows[@]}"; do for row in "${summary_rows[@]}"; do
IFS='|' read -r name_cell size_cell last_cell <<< "$row" IFS='|' read -r name_cell size_cell last_cell <<< "$row"
# Calculate printf width based on actual display width
local name_display_width local name_display_width
name_display_width=$(get_display_width "$name_cell") name_display_width=$(get_display_width "$name_cell")
local name_char_count=${#name_cell} local name_char_count=${#name_cell}
@@ -592,30 +544,24 @@ main() {
((index++)) ((index++))
done done
# Execute batch uninstallation (handles confirmation)
batch_uninstall_applications batch_uninstall_applications
# Cleanup current apps file
rm -f "$apps_file" rm -f "$apps_file"
# Pause before looping back
echo -e "${GRAY}Press Enter to return to application list, any other key to exit...${NC}" echo -e "${GRAY}Press Enter to return to application list, any other key to exit...${NC}"
local key local key
IFS= read -r -s -n1 key || key="" IFS= read -r -s -n1 key || key=""
drain_pending_input drain_pending_input
# Logic: Enter = continue loop, any other key = exit
if [[ -z "$key" ]]; then if [[ -z "$key" ]]; then
: # Enter pressed, continue loop :
else else
show_cursor show_cursor
return 0 return 0
fi fi
# Reset force_rescan to false for subsequent loops
force_rescan=false force_rescan=false
done done
} }
# Run main function
main "$@" main "$@"

View File

@@ -75,7 +75,7 @@ scan_applications() {
local cache_ttl=86400 # 24 hours local cache_ttl=86400 # 24 hours
local force_rescan="${1:-false}" local force_rescan="${1:-false}"
mkdir -p "$cache_dir" 2> /dev/null ensure_user_dir "$cache_dir"
# Check if cache exists and is fresh # Check if cache exists and is fresh
if [[ $force_rescan == false && -f "$cache_file" ]]; then if [[ $force_rescan == false && -f "$cache_file" ]]; then
@@ -111,40 +111,61 @@ scan_applications() {
# First pass: quickly collect all valid app paths and bundle IDs (NO mdls calls) # First pass: quickly collect all valid app paths and bundle IDs (NO mdls calls)
local -a app_data_tuples=() local -a app_data_tuples=()
while IFS= read -r -d '' app_path; do local -a app_dirs=(
if [[ ! -e "$app_path" ]]; then continue; fi "/Applications"
"$HOME/Applications"
local app_name
app_name=$(basename "$app_path" .app)
# Skip nested apps (e.g. inside Wrapper/ or Frameworks/ of another app)
# Check if parent path component ends in .app (e.g. /Foo.app/Bar.app or /Foo.app/Contents/Bar.app)
# This prevents false positives like /Old.apps/Target.app
local parent_dir
parent_dir=$(dirname "$app_path")
if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then
continue
fi
# Get bundle ID only (fast, no mdls calls in first pass)
local bundle_id="unknown"
if [[ -f "$app_path/Contents/Info.plist" ]]; then
bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown")
fi
# Skip system critical apps (input methods, system components)
if should_protect_from_uninstall "$bundle_id"; then
continue
fi
# Store tuple: app_path|app_name|bundle_id (display_name will be resolved in parallel later)
app_data_tuples+=("${app_path}|${app_name}|${bundle_id}")
done < <(
# Scan both system and user application directories
# Using maxdepth 3 to find apps in subdirectories (e.g., Adobe apps in /Applications/Adobe X/)
command find /Applications -name "*.app" -maxdepth 3 -print0 2> /dev/null
command find ~/Applications -name "*.app" -maxdepth 3 -print0 2> /dev/null
) )
local vol_app_dir
local nullglob_was_set=0
shopt -q nullglob && nullglob_was_set=1
shopt -s nullglob
for vol_app_dir in /Volumes/*/Applications; do
[[ -d "$vol_app_dir" && -r "$vol_app_dir" ]] || continue
if [[ -d "/Applications" && "$vol_app_dir" -ef "/Applications" ]]; then
continue
fi
if [[ -d "$HOME/Applications" && "$vol_app_dir" -ef "$HOME/Applications" ]]; then
continue
fi
app_dirs+=("$vol_app_dir")
done
if [[ $nullglob_was_set -eq 0 ]]; then
shopt -u nullglob
fi
for app_dir in "${app_dirs[@]}"; do
if [[ ! -d "$app_dir" ]]; then continue; fi
while IFS= read -r -d '' app_path; do
if [[ ! -e "$app_path" ]]; then continue; fi
local app_name
app_name=$(basename "$app_path" .app)
# Skip nested apps (e.g. inside Wrapper/ or Frameworks/ of another app)
# Check if parent path component ends in .app (e.g. /Foo.app/Bar.app or /Foo.app/Contents/Bar.app)
# This prevents false positives like /Old.apps/Target.app
local parent_dir
parent_dir=$(dirname "$app_path")
if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then
continue
fi
# Get bundle ID only (fast, no mdls calls in first pass)
local bundle_id="unknown"
if [[ -f "$app_path/Contents/Info.plist" ]]; then
bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown")
fi
# Skip system critical apps (input methods, system components)
if should_protect_from_uninstall "$bundle_id"; then
continue
fi
# Store tuple: app_path|app_name|bundle_id (display_name will be resolved in parallel later)
app_data_tuples+=("${app_path}|${app_name}|${bundle_id}")
done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null)
done
# Second pass: process each app with parallel size calculation # Second pass: process each app with parallel size calculation
local app_count=0 local app_count=0
@@ -210,9 +231,9 @@ scan_applications() {
local last_used_epoch=0 local last_used_epoch=0
if [[ -d "$app_path" ]]; then if [[ -d "$app_path" ]]; then
# Try mdls first with short timeout (0.05s) for accuracy, fallback to mtime for speed # Try mdls first with short timeout (0.1s) for accuracy, fallback to mtime for speed
local metadata_date local metadata_date
metadata_date=$(run_with_timeout 0.05 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") metadata_date=$(run_with_timeout 0.1 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "")
if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then
last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0")
@@ -259,7 +280,9 @@ scan_applications() {
# Start a background spinner that reads progress from file # Start a background spinner that reads progress from file
local spinner_pid="" local spinner_pid=""
( (
trap 'exit 0' TERM INT EXIT # shellcheck disable=SC2329 # Function invoked indirectly via trap
cleanup_spinner() { exit 0; }
trap cleanup_spinner TERM INT EXIT
local spinner_chars="|/-\\" local spinner_chars="|/-\\"
local i=0 local i=0
while true; do while true; do
@@ -344,6 +367,7 @@ scan_applications() {
fi fi
# Save to cache (simplified - no metadata) # Save to cache (simplified - no metadata)
ensure_user_file "$cache_file"
cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true
# Return sorted file # Return sorted file
@@ -354,7 +378,6 @@ scan_applications() {
fi fi
} }
# Load applications into arrays
load_applications() { load_applications() {
local apps_file="$1" local apps_file="$1"
@@ -403,9 +426,7 @@ cleanup() {
# Set trap for cleanup on exit # Set trap for cleanup on exit
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
# Main function
main() { main() {
# Parse args
local force_rescan=false local force_rescan=false
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
@@ -548,7 +569,43 @@ main() {
local -a summary_rows=() local -a summary_rows=()
local max_name_width=0 local max_name_width=0
local max_size_width=0 local max_size_width=0
local name_trunc_limit=30 local max_last_width=0
# First pass: get actual max widths for all columns
for selected_app in "${selected_apps[@]}"; do
IFS='|' read -r _ _ app_name _ size last_used _ <<< "$selected_app"
[[ ${#app_name} -gt $max_name_width ]] && max_name_width=${#app_name}
local size_display="$size"
[[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]] && size_display="Unknown"
[[ ${#size_display} -gt $max_size_width ]] && max_size_width=${#size_display}
local last_display=$(format_last_used_summary "$last_used")
[[ ${#last_display} -gt $max_last_width ]] && max_last_width=${#last_display}
done
((max_size_width < 5)) && max_size_width=5
((max_last_width < 5)) && max_last_width=5
# Calculate name width: use actual max, but constrain by terminal width
# Fixed elements: "99. " (4) + " " (2) + " | Last: " (11) = 17
local term_width=$(tput cols 2> /dev/null || echo 100)
local available_for_name=$((term_width - 17 - max_size_width - max_last_width))
# Dynamic minimum for better spacing on wide terminals
local min_name_width=24
if [[ $term_width -ge 120 ]]; then
min_name_width=50
elif [[ $term_width -ge 100 ]]; then
min_name_width=42
elif [[ $term_width -ge 80 ]]; then
min_name_width=30
fi
# Constrain name width: dynamic min, max min(actual_max, available, 60)
local name_trunc_limit=$max_name_width
[[ $name_trunc_limit -lt $min_name_width ]] && name_trunc_limit=$min_name_width
[[ $name_trunc_limit -gt $available_for_name ]] && name_trunc_limit=$available_for_name
[[ $name_trunc_limit -gt 60 ]] && name_trunc_limit=60
# Reset for second pass
max_name_width=0
for selected_app in "${selected_apps[@]}"; do for selected_app in "${selected_apps[@]}"; do
IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$selected_app" IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$selected_app"
@@ -563,7 +620,6 @@ main() {
if [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]]; then if [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]]; then
size_display="Unknown" size_display="Unknown"
fi fi
[[ ${#size_display} -gt $max_size_width ]] && max_size_width=${#size_display}
local last_display local last_display
last_display=$(format_last_used_summary "$last_used") last_display=$(format_last_used_summary "$last_used")
@@ -572,7 +628,6 @@ main() {
done done
((max_name_width < 16)) && max_name_width=16 ((max_name_width < 16)) && max_name_width=16
((max_size_width < 5)) && max_size_width=5
local index=1 local index=1
for row in "${summary_rows[@]}"; do for row in "${summary_rows[@]}"; do

View File

@@ -75,11 +75,6 @@ func TestScanPathConcurrentBasic(t *testing.T) {
if bytes := atomic.LoadInt64(&bytesScanned); bytes == 0 { if bytes := atomic.LoadInt64(&bytesScanned); bytes == 0 {
t.Fatalf("expected byte counter to increase") t.Fatalf("expected byte counter to increase")
} }
// current path update is throttled, so it might be empty for small scans
// if current == "" {
// t.Fatalf("expected current path to be updated")
// }
foundSymlink := false foundSymlink := false
for _, entry := range result.Entries { for _, entry := range result.Entries {
if strings.HasSuffix(entry.Name, " →") { if strings.HasSuffix(entry.Name, " →") {
@@ -148,7 +143,7 @@ func TestOverviewStoreAndLoad(t *testing.T) {
t.Fatalf("snapshot mismatch: want %d, got %d", want, got) t.Fatalf("snapshot mismatch: want %d, got %d", want, got)
} }
// Force reload from disk and ensure value persists. // Reload from disk and ensure value persists.
resetOverviewSnapshotForTest() resetOverviewSnapshotForTest()
got, err = loadStoredOverviewSize(path) got, err = loadStoredOverviewSize(path)
if err != nil { if err != nil {
@@ -220,7 +215,7 @@ func TestMeasureOverviewSize(t *testing.T) {
t.Fatalf("expected positive size, got %d", size) t.Fatalf("expected positive size, got %d", size)
} }
// Ensure snapshot stored // Ensure snapshot stored.
cached, err := loadStoredOverviewSize(target) cached, err := loadStoredOverviewSize(target)
if err != nil { if err != nil {
t.Fatalf("loadStoredOverviewSize: %v", err) t.Fatalf("loadStoredOverviewSize: %v", err)
@@ -279,13 +274,13 @@ func TestLoadCacheExpiresWhenDirectoryChanges(t *testing.T) {
t.Fatalf("saveCacheToDisk: %v", err) t.Fatalf("saveCacheToDisk: %v", err)
} }
// Touch directory to advance mtime beyond grace period. // Advance mtime beyond grace period.
time.Sleep(time.Millisecond * 10) time.Sleep(time.Millisecond * 10)
if err := os.Chtimes(target, time.Now(), time.Now()); err != nil { if err := os.Chtimes(target, time.Now(), time.Now()); err != nil {
t.Fatalf("chtimes: %v", err) t.Fatalf("chtimes: %v", err)
} }
// Force modtime difference beyond grace window by simulating an older cache entry. // Simulate older cache entry to exceed grace window.
cachePath, err := getCachePath(target) cachePath, err := getCachePath(target)
if err != nil { if err != nil {
t.Fatalf("getCachePath: %v", err) t.Fatalf("getCachePath: %v", err)
@@ -335,24 +330,24 @@ func TestScanPathPermissionError(t *testing.T) {
t.Fatalf("create locked dir: %v", err) t.Fatalf("create locked dir: %v", err)
} }
// Create a file inside before locking, just to be sure // Create a file before locking.
if err := os.WriteFile(filepath.Join(lockedDir, "secret.txt"), []byte("shh"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(lockedDir, "secret.txt"), []byte("shh"), 0o644); err != nil {
t.Fatalf("write secret: %v", err) t.Fatalf("write secret: %v", err)
} }
// Remove permissions // Remove permissions.
if err := os.Chmod(lockedDir, 0o000); err != nil { if err := os.Chmod(lockedDir, 0o000); err != nil {
t.Fatalf("chmod 000: %v", err) t.Fatalf("chmod 000: %v", err)
} }
defer func() { defer func() {
// Restore permissions so cleanup can work // Restore permissions for cleanup.
_ = os.Chmod(lockedDir, 0o755) _ = os.Chmod(lockedDir, 0o755)
}() }()
var files, dirs, bytes int64 var files, dirs, bytes int64
current := "" current := ""
// Scanning the locked dir itself should fail // Scanning the locked dir itself should fail.
_, err := scanPathConcurrent(lockedDir, &files, &dirs, &bytes, &current) _, err := scanPathConcurrent(lockedDir, &files, &dirs, &bytes, &current)
if err == nil { if err == nil {
t.Fatalf("expected error scanning locked directory, got nil") t.Fatalf("expected error scanning locked directory, got nil")

View File

@@ -222,7 +222,7 @@ func loadCacheFromDisk(path string) (*cacheEntry, error) {
} }
if info.ModTime().After(entry.ModTime) { if info.ModTime().After(entry.ModTime) {
// Only expire cache if the directory has been newer for longer than the grace window. // Allow grace window.
if cacheModTimeGrace <= 0 || info.ModTime().Sub(entry.ModTime) > cacheModTimeGrace { if cacheModTimeGrace <= 0 || info.ModTime().Sub(entry.ModTime) > cacheModTimeGrace {
return nil, fmt.Errorf("cache expired: directory modified") return nil, fmt.Errorf("cache expired: directory modified")
} }
@@ -290,29 +290,23 @@ func removeOverviewSnapshot(path string) {
} }
} }
// prefetchOverviewCache scans overview directories in background // prefetchOverviewCache warms overview cache in background.
// to populate cache for faster overview mode access
func prefetchOverviewCache(ctx context.Context) { func prefetchOverviewCache(ctx context.Context) {
entries := createOverviewEntries() entries := createOverviewEntries()
// Check which entries need refresh
var needScan []string var needScan []string
for _, entry := range entries { for _, entry := range entries {
// Skip if we have fresh cache
if size, err := loadStoredOverviewSize(entry.Path); err == nil && size > 0 { if size, err := loadStoredOverviewSize(entry.Path); err == nil && size > 0 {
continue continue
} }
needScan = append(needScan, entry.Path) needScan = append(needScan, entry.Path)
} }
// Nothing to scan
if len(needScan) == 0 { if len(needScan) == 0 {
return return
} }
// Scan and cache in background with context cancellation support
for _, path := range needScan { for _, path := range needScan {
// Check if context is cancelled
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return

View File

@@ -5,23 +5,20 @@ import (
"strings" "strings"
) )
// isCleanableDir checks if a directory is safe to manually delete // isCleanableDir marks paths safe to delete manually (not handled by mo clean).
// but NOT cleaned by mo clean (so user might want to delete it manually)
func isCleanableDir(path string) bool { func isCleanableDir(path string) bool {
if path == "" { if path == "" {
return false return false
} }
// Exclude paths that mo clean will handle automatically // Exclude paths mo clean already handles.
// These are system caches/logs that mo clean already processes
if isHandledByMoClean(path) { if isHandledByMoClean(path) {
return false return false
} }
baseName := filepath.Base(path) baseName := filepath.Base(path)
// Only mark project dependencies and build outputs // Project dependencies and build outputs are safe.
// These are safe to delete but mo clean won't touch them
if projectDependencyDirs[baseName] { if projectDependencyDirs[baseName] {
return true return true
} }
@@ -29,9 +26,8 @@ func isCleanableDir(path string) bool {
return false return false
} }
// isHandledByMoClean checks if this path will be cleaned by mo clean // isHandledByMoClean checks if a path is cleaned by mo clean.
func isHandledByMoClean(path string) bool { func isHandledByMoClean(path string) bool {
// Paths that mo clean handles (from clean.sh)
cleanPaths := []string{ cleanPaths := []string{
"/Library/Caches/", "/Library/Caches/",
"/Library/Logs/", "/Library/Logs/",
@@ -49,16 +45,15 @@ func isHandledByMoClean(path string) bool {
return false return false
} }
// Project dependency and build directories // Project dependency and build directories.
// These are safe to delete manually but mo clean won't touch them
var projectDependencyDirs = map[string]bool{ var projectDependencyDirs = map[string]bool{
// JavaScript/Node dependencies // JavaScript/Node.
"node_modules": true, "node_modules": true,
"bower_components": true, "bower_components": true,
".yarn": true, // Yarn local cache ".yarn": true,
".pnpm-store": true, // pnpm store ".pnpm-store": true,
// Python dependencies and outputs // Python.
"venv": true, "venv": true,
".venv": true, ".venv": true,
"virtualenv": true, "virtualenv": true,
@@ -68,18 +63,18 @@ var projectDependencyDirs = map[string]bool{
".ruff_cache": true, ".ruff_cache": true,
".tox": true, ".tox": true,
".eggs": true, ".eggs": true,
"htmlcov": true, // Coverage reports "htmlcov": true,
".ipynb_checkpoints": true, // Jupyter checkpoints ".ipynb_checkpoints": true,
// Ruby dependencies // Ruby.
"vendor": true, "vendor": true,
".bundle": true, ".bundle": true,
// Java/Kotlin/Scala // Java/Kotlin/Scala.
".gradle": true, // Project-level Gradle cache ".gradle": true,
"out": true, // IntelliJ IDEA build output "out": true,
// Build outputs (can be rebuilt) // Build outputs.
"build": true, "build": true,
"dist": true, "dist": true,
"target": true, "target": true,
@@ -88,24 +83,25 @@ var projectDependencyDirs = map[string]bool{
".output": true, ".output": true,
".parcel-cache": true, ".parcel-cache": true,
".turbo": true, ".turbo": true,
".vite": true, // Vite cache ".vite": true,
".nx": true, // Nx cache ".nx": true,
"coverage": true, "coverage": true,
".coverage": true, ".coverage": true,
".nyc_output": true, // NYC coverage ".nyc_output": true,
// Frontend framework outputs // Frontend framework outputs.
".angular": true, // Angular CLI cache ".angular": true,
".svelte-kit": true, // SvelteKit build ".svelte-kit": true,
".astro": true, // Astro cache ".astro": true,
".docusaurus": true, // Docusaurus build ".docusaurus": true,
// iOS/macOS development // Apple dev.
"DerivedData": true, "DerivedData": true,
"Pods": true, "Pods": true,
".build": true, ".build": true,
"Carthage": true, "Carthage": true,
".dart_tool": true,
// Other tools // Other tools.
".terraform": true, // Terraform plugins ".terraform": true,
} }

View File

@@ -6,35 +6,35 @@ const (
maxEntries = 30 maxEntries = 30
maxLargeFiles = 30 maxLargeFiles = 30
barWidth = 24 barWidth = 24
minLargeFileSize = 100 << 20 // 100 MB minLargeFileSize = 100 << 20
defaultViewport = 12 // Default viewport when terminal height is unknown defaultViewport = 12
overviewCacheTTL = 7 * 24 * time.Hour // 7 days overviewCacheTTL = 7 * 24 * time.Hour
overviewCacheFile = "overview_sizes.json" overviewCacheFile = "overview_sizes.json"
duTimeout = 30 * time.Second // Fail faster to fallback to concurrent scan duTimeout = 30 * time.Second
mdlsTimeout = 5 * time.Second mdlsTimeout = 5 * time.Second
maxConcurrentOverview = 8 // Increased parallel overview scans maxConcurrentOverview = 8
batchUpdateSize = 100 // Batch atomic updates every N items batchUpdateSize = 100
cacheModTimeGrace = 30 * time.Minute // Ignore minor directory mtime bumps cacheModTimeGrace = 30 * time.Minute
// Worker pool configuration // Worker pool limits.
minWorkers = 16 // Safe baseline for older machines minWorkers = 16
maxWorkers = 64 // Cap at 64 to avoid OS resource contention maxWorkers = 64
cpuMultiplier = 4 // Balanced CPU usage cpuMultiplier = 4
maxDirWorkers = 32 // Limit concurrent subdirectory scans maxDirWorkers = 32
openCommandTimeout = 10 * time.Second // Timeout for open/reveal commands openCommandTimeout = 10 * time.Second
) )
var foldDirs = map[string]bool{ var foldDirs = map[string]bool{
// Version control // VCS.
".git": true, ".git": true,
".svn": true, ".svn": true,
".hg": true, ".hg": true,
// JavaScript/Node // JavaScript/Node.
"node_modules": true, "node_modules": true,
".npm": true, ".npm": true,
"_npx": true, // ~/.npm/_npx global cache "_npx": true,
"_cacache": true, // ~/.npm/_cacache "_cacache": true,
"_logs": true, "_logs": true,
"_locks": true, "_locks": true,
"_quick": true, "_quick": true,
@@ -56,7 +56,7 @@ var foldDirs = map[string]bool{
".bun": true, ".bun": true,
".deno": true, ".deno": true,
// Python // Python.
"__pycache__": true, "__pycache__": true,
".pytest_cache": true, ".pytest_cache": true,
".mypy_cache": true, ".mypy_cache": true,
@@ -73,7 +73,7 @@ var foldDirs = map[string]bool{
".pip": true, ".pip": true,
".pipx": true, ".pipx": true,
// Ruby/Go/PHP (vendor), Java/Kotlin/Scala/Rust (target) // Ruby/Go/PHP (vendor), Java/Kotlin/Scala/Rust (target).
"vendor": true, "vendor": true,
".bundle": true, ".bundle": true,
"gems": true, "gems": true,
@@ -88,20 +88,20 @@ var foldDirs = map[string]bool{
".composer": true, ".composer": true,
".cargo": true, ".cargo": true,
// Build outputs // Build outputs.
"build": true, "build": true,
"dist": true, "dist": true,
".output": true, ".output": true,
"coverage": true, "coverage": true,
".coverage": true, ".coverage": true,
// IDE // IDE.
".idea": true, ".idea": true,
".vscode": true, ".vscode": true,
".vs": true, ".vs": true,
".fleet": true, ".fleet": true,
// Cache directories // Cache directories.
".cache": true, ".cache": true,
"__MACOSX": true, "__MACOSX": true,
".DS_Store": true, ".DS_Store": true,
@@ -121,36 +121,37 @@ var foldDirs = map[string]bool{
".sdkman": true, ".sdkman": true,
".nvm": true, ".nvm": true,
// macOS specific // macOS.
"Application Scripts": true, "Application Scripts": true,
"Saved Application State": true, "Saved Application State": true,
// iCloud // iCloud.
"Mobile Documents": true, "Mobile Documents": true,
// Docker & Containers // Containers.
".docker": true, ".docker": true,
".containerd": true, ".containerd": true,
// Mobile development // Mobile development.
"Pods": true, "Pods": true,
"DerivedData": true, "DerivedData": true,
".build": true, ".build": true,
"xcuserdata": true, "xcuserdata": true,
"Carthage": true, "Carthage": true,
".dart_tool": true,
// Web frameworks // Web frameworks.
".angular": true, ".angular": true,
".svelte-kit": true, ".svelte-kit": true,
".astro": true, ".astro": true,
".solid": true, ".solid": true,
// Databases // Databases.
".mysql": true, ".mysql": true,
".postgres": true, ".postgres": true,
"mongodb": true, "mongodb": true,
// Other // Other.
".terraform": true, ".terraform": true,
".vagrant": true, ".vagrant": true,
"tmp": true, "tmp": true,
@@ -169,22 +170,22 @@ var skipSystemDirs = map[string]bool{
"bin": true, "bin": true,
"etc": true, "etc": true,
"var": true, "var": true,
"opt": false, // User might want to specific check opt "opt": false,
"usr": false, // User might check usr "usr": false,
"Volumes": true, // Skip external drives by default when scanning root "Volumes": true,
"Network": true, // Skip network mounts "Network": true,
".vol": true, ".vol": true,
".Spotlight-V100": true, ".Spotlight-V100": true,
".fseventsd": true, ".fseventsd": true,
".DocumentRevisions-V100": true, ".DocumentRevisions-V100": true,
".TemporaryItems": true, ".TemporaryItems": true,
".MobileBackups": true, // Time Machine local snapshots ".MobileBackups": true,
} }
var defaultSkipDirs = map[string]bool{ var defaultSkipDirs = map[string]bool{
"nfs": true, // Network File System "nfs": true,
"PHD": true, // Parallels Shared Folders / Home Directories "PHD": true,
"Permissions": true, // Common macOS deny folder "Permissions": true,
} }
var skipExtensions = map[string]bool{ var skipExtensions = map[string]bool{

View File

@@ -4,6 +4,8 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings"
"sync/atomic" "sync/atomic"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -21,20 +23,68 @@ func deletePathCmd(path string, counter *int64) tea.Cmd {
} }
} }
// deleteMultiplePathsCmd deletes paths and aggregates results.
func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd {
return func() tea.Msg {
var totalCount int64
var errors []string
// Delete deeper paths first to avoid parent/child conflicts.
pathsToDelete := append([]string(nil), paths...)
sort.Slice(pathsToDelete, func(i, j int) bool {
return strings.Count(pathsToDelete[i], string(filepath.Separator)) > strings.Count(pathsToDelete[j], string(filepath.Separator))
})
for _, path := range pathsToDelete {
count, err := deletePathWithProgress(path, counter)
totalCount += count
if err != nil {
if os.IsNotExist(err) {
continue
}
errors = append(errors, err.Error())
}
}
var resultErr error
if len(errors) > 0 {
resultErr = &multiDeleteError{errors: errors}
}
return deleteProgressMsg{
done: true,
err: resultErr,
count: totalCount,
path: "",
}
}
}
// multiDeleteError holds multiple deletion errors.
type multiDeleteError struct {
errors []string
}
func (e *multiDeleteError) Error() string {
if len(e.errors) == 1 {
return e.errors[0]
}
return strings.Join(e.errors[:min(3, len(e.errors))], "; ")
}
func deletePathWithProgress(root string, counter *int64) (int64, error) { func deletePathWithProgress(root string, counter *int64) (int64, error) {
var count int64 var count int64
var firstErr error var firstErr error
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
// Skip permission errors but continue walking // Skip permission errors but continue.
if os.IsPermission(err) { if os.IsPermission(err) {
if firstErr == nil { if firstErr == nil {
firstErr = err firstErr = err
} }
return filepath.SkipDir return filepath.SkipDir
} }
// For other errors, record and continue
if firstErr == nil { if firstErr == nil {
firstErr = err firstErr = err
} }
@@ -48,7 +98,6 @@ func deletePathWithProgress(root string, counter *int64) (int64, error) {
atomic.StoreInt64(counter, count) atomic.StoreInt64(counter, count)
} }
} else if firstErr == nil { } else if firstErr == nil {
// Record first deletion error
firstErr = removeErr firstErr = removeErr
} }
} }
@@ -56,19 +105,15 @@ func deletePathWithProgress(root string, counter *int64) (int64, error) {
return nil return nil
}) })
// Track walk error separately
if err != nil && firstErr == nil { if err != nil && firstErr == nil {
firstErr = err firstErr = err
} }
// Try to remove remaining directory structure
// Even if this fails, we still report files deleted
if removeErr := os.RemoveAll(root); removeErr != nil { if removeErr := os.RemoveAll(root); removeErr != nil {
if firstErr == nil { if firstErr == nil {
firstErr = removeErr firstErr = removeErr
} }
} }
// Always return count (even if there were errors), along with first error
return count, firstErr return count, firstErr
} }

View File

@@ -0,0 +1,43 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestDeleteMultiplePathsCmdHandlesParentChild(t *testing.T) {
base := t.TempDir()
parent := filepath.Join(base, "parent")
child := filepath.Join(parent, "child")
// Structure: parent/fileA, parent/child/fileC.
if err := os.MkdirAll(child, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(parent, "fileA"), []byte("a"), 0o644); err != nil {
t.Fatalf("write fileA: %v", err)
}
if err := os.WriteFile(filepath.Join(child, "fileC"), []byte("c"), 0o644); err != nil {
t.Fatalf("write fileC: %v", err)
}
var counter int64
msg := deleteMultiplePathsCmd([]string{parent, child}, &counter)()
progress, ok := msg.(deleteProgressMsg)
if !ok {
t.Fatalf("expected deleteProgressMsg, got %T", msg)
}
if progress.err != nil {
t.Fatalf("unexpected error: %v", progress.err)
}
if progress.count != 2 {
t.Fatalf("expected 2 files deleted, got %d", progress.count)
}
if _, err := os.Stat(parent); !os.IsNotExist(err) {
t.Fatalf("expected parent to be removed, err=%v", err)
}
if _, err := os.Stat(child); !os.IsNotExist(err) {
t.Fatalf("expected child to be removed, err=%v", err)
}
}

View File

@@ -18,7 +18,7 @@ func displayPath(path string) string {
return path return path
} }
// truncateMiddle truncates string in the middle, keeping head and tail. // truncateMiddle trims the middle, keeping head and tail.
func truncateMiddle(s string, maxWidth int) string { func truncateMiddle(s string, maxWidth int) string {
runes := []rune(s) runes := []rune(s)
currentWidth := displayWidth(s) currentWidth := displayWidth(s)
@@ -27,9 +27,7 @@ func truncateMiddle(s string, maxWidth int) string {
return s return s
} }
// Reserve 3 width for "..."
if maxWidth < 10 { if maxWidth < 10 {
// Simple truncation for very small width
width := 0 width := 0
for i, r := range runes { for i, r := range runes {
width += runeWidth(r) width += runeWidth(r)
@@ -40,11 +38,9 @@ func truncateMiddle(s string, maxWidth int) string {
return s return s
} }
// Keep more of the tail (filename usually more important)
targetHeadWidth := (maxWidth - 3) / 3 targetHeadWidth := (maxWidth - 3) / 3
targetTailWidth := maxWidth - 3 - targetHeadWidth targetTailWidth := maxWidth - 3 - targetHeadWidth
// Find head cutoff point based on display width
headWidth := 0 headWidth := 0
headIdx := 0 headIdx := 0
for i, r := range runes { for i, r := range runes {
@@ -56,7 +52,6 @@ func truncateMiddle(s string, maxWidth int) string {
headIdx = i + 1 headIdx = i + 1
} }
// Find tail cutoff point
tailWidth := 0 tailWidth := 0
tailIdx := len(runes) tailIdx := len(runes)
for i := len(runes) - 1; i >= 0; i-- { for i := len(runes) - 1; i >= 0; i-- {
@@ -108,7 +103,6 @@ func coloredProgressBar(value, max int64, percent float64) string {
filled = barWidth filled = barWidth
} }
// Choose color based on percentage
var barColor string var barColor string
if percent >= 50 { if percent >= 50 {
barColor = colorRed barColor = colorRed
@@ -142,12 +136,24 @@ func coloredProgressBar(value, max int64, percent float64) string {
return bar + colorReset return bar + colorReset
} }
// Calculate display width considering CJK characters. // runeWidth returns display width for wide characters and emoji.
func runeWidth(r rune) int { func runeWidth(r rune) int {
if r >= 0x4E00 && r <= 0x9FFF || if r >= 0x4E00 && r <= 0x9FFF || // CJK Unified Ideographs
r >= 0x3400 && r <= 0x4DBF || r >= 0x3400 && r <= 0x4DBF || // CJK Extension A
r >= 0xAC00 && r <= 0xD7AF || r >= 0x20000 && r <= 0x2A6DF || // CJK Extension B
r >= 0xFF00 && r <= 0xFFEF { r >= 0x2A700 && r <= 0x2B73F || // CJK Extension C
r >= 0x2B740 && r <= 0x2B81F || // CJK Extension D
r >= 0x2B820 && r <= 0x2CEAF || // CJK Extension E
r >= 0x3040 && r <= 0x30FF || // Hiragana and Katakana
r >= 0x31F0 && r <= 0x31FF || // Katakana Phonetic Extensions
r >= 0xAC00 && r <= 0xD7AF || // Hangul Syllables
r >= 0xFF00 && r <= 0xFFEF || // Fullwidth Forms
r >= 0x1F300 && r <= 0x1F6FF || // Miscellaneous Symbols and Pictographs (includes Transport)
r >= 0x1F900 && r <= 0x1F9FF || // Supplemental Symbols and Pictographs
r >= 0x2600 && r <= 0x26FF || // Miscellaneous Symbols
r >= 0x2700 && r <= 0x27BF || // Dingbats
r >= 0xFE10 && r <= 0xFE1F || // Vertical Forms
r >= 0x1F000 && r <= 0x1F02F { // Mahjong Tiles
return 2 return 2
} }
return 1 return 1
@@ -161,9 +167,26 @@ func displayWidth(s string) int {
return width return width
} }
// calculateNameWidth computes name column width from terminal width.
func calculateNameWidth(termWidth int) int {
const fixedWidth = 61
available := termWidth - fixedWidth
if available < 24 {
return 24
}
if available > 60 {
return 60
}
return available
}
func trimName(name string) string { func trimName(name string) string {
return trimNameWithWidth(name, 45) // Default width for backward compatibility
}
func trimNameWithWidth(name string, maxWidth int) string {
const ( const (
maxWidth = 28
ellipsis = "..." ellipsis = "..."
ellipsisWidth = 3 ellipsisWidth = 3
) )
@@ -202,7 +225,7 @@ func padName(name string, targetWidth int) string {
return name + strings.Repeat(" ", targetWidth-currentWidth) return name + strings.Repeat(" ", targetWidth-currentWidth)
} }
// formatUnusedTime formats the time since last access in a compact way. // formatUnusedTime formats time since last access.
func formatUnusedTime(lastAccess time.Time) string { func formatUnusedTime(lastAccess time.Time) string {
if lastAccess.IsZero() { if lastAccess.IsZero() {
return "" return ""

309
cmd/analyze/format_test.go Normal file
View File

@@ -0,0 +1,309 @@
package main
import (
"strings"
"testing"
)
func TestRuneWidth(t *testing.T) {
tests := []struct {
name string
input rune
want int
}{
{"ASCII letter", 'a', 1},
{"ASCII digit", '5', 1},
{"Chinese character", '中', 2},
{"Japanese hiragana", 'あ', 2},
{"Korean hangul", '한', 2},
{"CJK ideograph", '語', 2},
{"Full-width number", '', 2},
{"ASCII space", ' ', 1},
{"Tab", '\t', 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := runeWidth(tt.input); got != tt.want {
t.Errorf("runeWidth(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
func TestDisplayWidth(t *testing.T) {
tests := []struct {
name string
input string
want int
}{
{"Empty string", "", 0},
{"ASCII only", "hello", 5},
{"Chinese only", "你好", 4},
{"Mixed ASCII and CJK", "hello世界", 9}, // 5 + 4
{"Path with CJK", "/Users/张三/文件", 16}, // 7 (ASCII) + 4 (张三) + 4 (文件) + 1 (/) = 16
{"Full-width chars", "", 6},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := displayWidth(tt.input); got != tt.want {
t.Errorf("displayWidth(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
func TestHumanizeBytes(t *testing.T) {
tests := []struct {
input int64
want string
}{
{-100, "0 B"},
{0, "0 B"},
{512, "512 B"},
{1023, "1023 B"},
{1024, "1.0 KB"},
{1536, "1.5 KB"},
{10240, "10.0 KB"},
{1048576, "1.0 MB"},
{1572864, "1.5 MB"},
{1073741824, "1.0 GB"},
{1099511627776, "1.0 TB"},
{1125899906842624, "1.0 PB"},
}
for _, tt := range tests {
got := humanizeBytes(tt.input)
if got != tt.want {
t.Errorf("humanizeBytes(%d) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestFormatNumber(t *testing.T) {
tests := []struct {
input int64
want string
}{
{0, "0"},
{500, "500"},
{999, "999"},
{1000, "1.0k"},
{1500, "1.5k"},
{999999, "1000.0k"},
{1000000, "1.0M"},
{1500000, "1.5M"},
}
for _, tt := range tests {
got := formatNumber(tt.input)
if got != tt.want {
t.Errorf("formatNumber(%d) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestTruncateMiddle(t *testing.T) {
tests := []struct {
name string
input string
maxWidth int
check func(t *testing.T, result string)
}{
{
name: "No truncation needed",
input: "short",
maxWidth: 10,
check: func(t *testing.T, result string) {
if result != "short" {
t.Errorf("Should not truncate short string, got %q", result)
}
},
},
{
name: "Truncate long ASCII",
input: "verylongfilename.txt",
maxWidth: 15,
check: func(t *testing.T, result string) {
if !strings.Contains(result, "...") {
t.Errorf("Truncated string should contain '...', got %q", result)
}
if displayWidth(result) > 15 {
t.Errorf("Truncated width %d exceeds max %d", displayWidth(result), 15)
}
},
},
{
name: "Truncate with CJK characters",
input: "非常长的中文文件名称.txt",
maxWidth: 20,
check: func(t *testing.T, result string) {
if !strings.Contains(result, "...") {
t.Errorf("Should truncate CJK string, got %q", result)
}
if displayWidth(result) > 20 {
t.Errorf("Truncated width %d exceeds max %d", displayWidth(result), 20)
}
},
},
{
name: "Very small width",
input: "longname",
maxWidth: 5,
check: func(t *testing.T, result string) {
if displayWidth(result) > 5 {
t.Errorf("Width %d exceeds max %d", displayWidth(result), 5)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := truncateMiddle(tt.input, tt.maxWidth)
tt.check(t, result)
})
}
}
func TestDisplayPath(t *testing.T) {
tests := []struct {
name string
setup func() string
check func(t *testing.T, result string)
}{
{
name: "Replace home directory",
setup: func() string {
home := t.TempDir()
t.Setenv("HOME", home)
return home + "/Documents/file.txt"
},
check: func(t *testing.T, result string) {
if !strings.HasPrefix(result, "~/") {
t.Errorf("Expected path to start with ~/, got %q", result)
}
if !strings.HasSuffix(result, "Documents/file.txt") {
t.Errorf("Expected path to end with Documents/file.txt, got %q", result)
}
},
},
{
name: "Keep absolute path outside home",
setup: func() string {
t.Setenv("HOME", "/Users/test")
return "/var/log/system.log"
},
check: func(t *testing.T, result string) {
if result != "/var/log/system.log" {
t.Errorf("Expected unchanged path, got %q", result)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := tt.setup()
result := displayPath(path)
tt.check(t, result)
})
}
}
func TestPadName(t *testing.T) {
tests := []struct {
name string
input string
targetWidth int
wantWidth int
}{
{"Pad ASCII", "test", 10, 10},
{"No padding needed", "longname", 5, 8},
{"Pad CJK", "中文", 10, 10},
{"Mixed CJK and ASCII", "hello世", 15, 15},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := padName(tt.input, tt.targetWidth)
gotWidth := displayWidth(result)
if gotWidth < tt.wantWidth && displayWidth(tt.input) < tt.targetWidth {
t.Errorf("padName(%q, %d) width = %d, want >= %d", tt.input, tt.targetWidth, gotWidth, tt.wantWidth)
}
})
}
}
func TestTrimNameWithWidth(t *testing.T) {
tests := []struct {
name string
input string
maxWidth int
check func(t *testing.T, result string)
}{
{
name: "Trim ASCII name",
input: "verylongfilename.txt",
maxWidth: 10,
check: func(t *testing.T, result string) {
if displayWidth(result) > 10 {
t.Errorf("Width exceeds max: %d > 10", displayWidth(result))
}
if !strings.HasSuffix(result, "...") {
t.Errorf("Expected ellipsis, got %q", result)
}
},
},
{
name: "Trim CJK name",
input: "很长的文件名称.txt",
maxWidth: 12,
check: func(t *testing.T, result string) {
if displayWidth(result) > 12 {
t.Errorf("Width exceeds max: %d > 12", displayWidth(result))
}
},
},
{
name: "No trimming needed",
input: "short.txt",
maxWidth: 20,
check: func(t *testing.T, result string) {
if result != "short.txt" {
t.Errorf("Should not trim, got %q", result)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := trimNameWithWidth(tt.input, tt.maxWidth)
tt.check(t, result)
})
}
}
func TestCalculateNameWidth(t *testing.T) {
tests := []struct {
termWidth int
wantMin int
wantMax int
}{
{80, 19, 60}, // 80 - 61 = 19
{120, 59, 60}, // 120 - 61 = 59
{200, 60, 60}, // Capped at 60
{70, 24, 60}, // Below minimum, use 24
{50, 24, 60}, // Very small, use minimum
}
for _, tt := range tests {
got := calculateNameWidth(tt.termWidth)
if got < tt.wantMin || got > tt.wantMax {
t.Errorf("calculateNameWidth(%d) = %d, want between %d and %d",
tt.termWidth, got, tt.wantMin, tt.wantMax)
}
}
}

View File

@@ -1,15 +1,10 @@
package main package main
// entryHeap implements heap.Interface for a min-heap of dirEntry (sorted by Size) // entryHeap is a min-heap of dirEntry used to keep Top N largest entries.
// Since we want Top N Largest, we use a Min Heap of size N.
// When adding a new item:
// 1. If heap size < N: push
// 2. If heap size == N and item > min (root): pop min, push item
// The heap will thus maintain the largest N items.
type entryHeap []dirEntry type entryHeap []dirEntry
func (h entryHeap) Len() int { return len(h) } func (h entryHeap) Len() int { return len(h) }
func (h entryHeap) Less(i, j int) bool { return h[i].Size < h[j].Size } // Min-heap based on 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 interface{}) {
@@ -24,7 +19,7 @@ func (h *entryHeap) Pop() interface{} {
return x return x
} }
// largeFileHeap implements heap.Interface for fileEntry // largeFileHeap is a min-heap for fileEntry.
type largeFileHeap []fileEntry type largeFileHeap []fileEntry
func (h largeFileHeap) Len() int { return len(h) } func (h largeFileHeap) Len() int { return len(h) }

View File

@@ -9,6 +9,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -111,6 +112,8 @@ type model struct {
overviewScanningSet map[string]bool // Track which paths are currently being scanned overviewScanningSet map[string]bool // Track which paths are currently being scanned
width int // Terminal width width int // Terminal width
height int // Terminal height height int // Terminal height
multiSelected map[string]bool // Track multi-selected items by path (safer than index)
largeMultiSelected map[string]bool // Track multi-selected large files by path (safer than index)
} }
func (m model) inOverviewMode() bool { func (m model) inOverviewMode() bool {
@@ -127,7 +130,6 @@ func main() {
var isOverview bool var isOverview bool
if target == "" { if target == "" {
// Default to overview mode
isOverview = true isOverview = true
abs = "/" abs = "/"
} else { } else {
@@ -140,8 +142,7 @@ func main() {
isOverview = false isOverview = false
} }
// Prefetch overview cache in background (non-blocking) // Warm overview cache in background.
// Use context with timeout to prevent hanging
prefetchCtx, prefetchCancel := context.WithTimeout(context.Background(), 30*time.Second) prefetchCtx, prefetchCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer prefetchCancel() defer prefetchCancel()
go prefetchOverviewCache(prefetchCtx) go prefetchOverviewCache(prefetchCtx)
@@ -177,9 +178,10 @@ func newModel(path string, isOverview bool) model {
overviewCurrentPath: &overviewCurrentPath, overviewCurrentPath: &overviewCurrentPath,
overviewSizeCache: make(map[string]int64), overviewSizeCache: make(map[string]int64),
overviewScanningSet: make(map[string]bool), overviewScanningSet: make(map[string]bool),
multiSelected: make(map[string]bool),
largeMultiSelected: make(map[string]bool),
} }
// In overview mode, create shortcut entries
if isOverview { if isOverview {
m.scanning = false m.scanning = false
m.hydrateOverviewEntries() m.hydrateOverviewEntries()
@@ -200,11 +202,14 @@ func createOverviewEntries() []dirEntry {
home := os.Getenv("HOME") home := os.Getenv("HOME")
entries := []dirEntry{} entries := []dirEntry{}
// Separate Home and ~/Library to avoid double counting.
if home != "" { if home != "" {
entries = append(entries, entries = append(entries, dirEntry{Name: "Home", Path: home, IsDir: true, Size: -1})
dirEntry{Name: "Home (~)", Path: home, IsDir: true, Size: -1},
dirEntry{Name: "Library (~/Library)", Path: filepath.Join(home, "Library"), IsDir: true, Size: -1}, userLibrary := filepath.Join(home, "Library")
) if _, err := os.Stat(userLibrary); err == nil {
entries = append(entries, dirEntry{Name: "App Library", Path: userLibrary, IsDir: true, Size: -1})
}
} }
entries = append(entries, entries = append(entries,
@@ -212,7 +217,7 @@ func createOverviewEntries() []dirEntry {
dirEntry{Name: "System Library", Path: "/Library", IsDir: true, Size: -1}, dirEntry{Name: "System Library", Path: "/Library", IsDir: true, Size: -1},
) )
// Add Volumes shortcut only when it contains real mounted folders (e.g., external disks) // Include Volumes only when real mounts exist.
if hasUsefulVolumeMounts("/Volumes") { if hasUsefulVolumeMounts("/Volumes") {
entries = append(entries, dirEntry{Name: "Volumes", Path: "/Volumes", IsDir: true, Size: -1}) entries = append(entries, dirEntry{Name: "Volumes", Path: "/Volumes", IsDir: true, Size: -1})
} }
@@ -228,7 +233,6 @@ func hasUsefulVolumeMounts(path string) bool {
for _, entry := range entries { for _, entry := range entries {
name := entry.Name() name := entry.Name()
// Skip hidden control entries for Spotlight/TimeMachine etc.
if strings.HasPrefix(name, ".") { if strings.HasPrefix(name, ".") {
continue continue
} }
@@ -265,12 +269,18 @@ func (m *model) hydrateOverviewEntries() {
m.totalSize = sumKnownEntrySizes(m.entries) m.totalSize = sumKnownEntrySizes(m.entries)
} }
func (m *model) sortOverviewEntriesBySize() {
// Stable sort by size.
sort.SliceStable(m.entries, func(i, j int) bool {
return m.entries[i].Size > m.entries[j].Size
})
}
func (m *model) scheduleOverviewScans() tea.Cmd { func (m *model) scheduleOverviewScans() tea.Cmd {
if !m.inOverviewMode() { if !m.inOverviewMode() {
return nil return nil
} }
// Find pending entries (not scanned and not currently scanning)
var pendingIndices []int var pendingIndices []int
for i, entry := range m.entries { for i, entry := range m.entries {
if entry.Size < 0 && !m.overviewScanningSet[entry.Path] { if entry.Size < 0 && !m.overviewScanningSet[entry.Path] {
@@ -281,16 +291,15 @@ func (m *model) scheduleOverviewScans() tea.Cmd {
} }
} }
// No more work to do
if len(pendingIndices) == 0 { if len(pendingIndices) == 0 {
m.overviewScanning = false m.overviewScanning = false
if !hasPendingOverviewEntries(m.entries) { if !hasPendingOverviewEntries(m.entries) {
m.sortOverviewEntriesBySize()
m.status = "Ready" m.status = "Ready"
} }
return nil return nil
} }
// Mark all as scanning
var cmds []tea.Cmd var cmds []tea.Cmd
for _, idx := range pendingIndices { for _, idx := range pendingIndices {
entry := m.entries[idx] entry := m.entries[idx]
@@ -341,7 +350,6 @@ func (m model) Init() tea.Cmd {
func (m model) scanCmd(path string) tea.Cmd { func (m model) scanCmd(path string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
// Try to load from persistent cache first
if cached, err := loadCacheFromDisk(path); err == nil { if cached, err := loadCacheFromDisk(path); err == nil {
result := scanResult{ result := scanResult{
Entries: cached.Entries, Entries: cached.Entries,
@@ -351,8 +359,6 @@ func (m model) scanCmd(path string) tea.Cmd {
return scanResultMsg{result: result, err: nil} return scanResultMsg{result: result, err: nil}
} }
// Use singleflight to avoid duplicate scans of the same path
// If multiple goroutines request the same path, only one scan will be performed
v, err, _ := scanGroup.Do(path, func() (interface{}, error) { v, err, _ := scanGroup.Do(path, func() (interface{}, error) {
return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath) return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath)
}) })
@@ -363,10 +369,8 @@ func (m model) scanCmd(path string) tea.Cmd {
result := v.(scanResult) result := v.(scanResult)
// Save to persistent cache asynchronously with error logging
go func(p string, r scanResult) { go func(p string, r scanResult) {
if err := saveCacheToDisk(p, r); err != nil { if err := saveCacheToDisk(p, r); err != nil {
// Log error but don't fail the scan
_ = err // Cache save failure is not critical _ = err // Cache save failure is not critical
} }
}(path, result) }(path, result)
@@ -392,6 +396,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case deleteProgressMsg: case deleteProgressMsg:
if msg.done { if msg.done {
m.deleting = false m.deleting = false
m.multiSelected = make(map[string]bool)
m.largeMultiSelected = make(map[string]bool)
if msg.err != nil { if msg.err != nil {
m.status = fmt.Sprintf("Failed to delete: %v", msg.err) m.status = fmt.Sprintf("Failed to delete: %v", msg.err)
} else { } else {
@@ -401,7 +407,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
invalidateCache(m.path) invalidateCache(m.path)
m.status = fmt.Sprintf("Deleted %d items", msg.count) m.status = fmt.Sprintf("Deleted %d items", msg.count)
// Mark all caches as dirty
for i := range m.history { for i := range m.history {
m.history[i].Dirty = true m.history[i].Dirty = true
} }
@@ -410,9 +415,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
entry.Dirty = true entry.Dirty = true
m.cache[path] = entry m.cache[path] = entry
} }
// Refresh the view
m.scanning = true m.scanning = true
// Reset scan counters for rescan
atomic.StoreInt64(m.filesScanned, 0) atomic.StoreInt64(m.filesScanned, 0)
atomic.StoreInt64(m.dirsScanned, 0) atomic.StoreInt64(m.dirsScanned, 0)
atomic.StoreInt64(m.bytesScanned, 0) atomic.StoreInt64(m.bytesScanned, 0)
@@ -429,7 +432,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.status = fmt.Sprintf("Scan failed: %v", msg.err) m.status = fmt.Sprintf("Scan failed: %v", msg.err)
return m, nil return m, nil
} }
// Filter out 0-byte items for cleaner view
filteredEntries := make([]dirEntry, 0, len(msg.result.Entries)) filteredEntries := make([]dirEntry, 0, len(msg.result.Entries))
for _, e := range msg.result.Entries { for _, e := range msg.result.Entries {
if e.Size > 0 { if e.Size > 0 {
@@ -454,7 +456,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case overviewSizeMsg: case overviewSizeMsg:
// Remove from scanning set
delete(m.overviewScanningSet, msg.Path) delete(m.overviewScanningSet, msg.Path)
if msg.Err == nil { if msg.Err == nil {
@@ -465,7 +466,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
if m.inOverviewMode() { if m.inOverviewMode() {
// Update entry with result
for i := range m.entries { for i := range m.entries {
if m.entries[i].Path == msg.Path { if m.entries[i].Path == msg.Path {
if msg.Err == nil { if msg.Err == nil {
@@ -478,18 +478,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.totalSize = sumKnownEntrySizes(m.entries) m.totalSize = sumKnownEntrySizes(m.entries)
// Show error briefly if any
if msg.Err != nil { if msg.Err != nil {
m.status = fmt.Sprintf("Unable to measure %s: %v", displayPath(msg.Path), msg.Err) m.status = fmt.Sprintf("Unable to measure %s: %v", displayPath(msg.Path), msg.Err)
} }
// Schedule next batch of scans
cmd := m.scheduleOverviewScans() cmd := m.scheduleOverviewScans()
return m, cmd return m, cmd
} }
return m, nil return m, nil
case tickMsg: case tickMsg:
// Keep spinner running if scanning or deleting or if there are pending overview items
hasPending := false hasPending := false
if m.inOverviewMode() { if m.inOverviewMode() {
for _, entry := range m.entries { for _, entry := range m.entries {
@@ -501,7 +498,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
if m.scanning || m.deleting || (m.inOverviewMode() && (m.overviewScanning || hasPending)) { if m.scanning || m.deleting || (m.inOverviewMode() && (m.overviewScanning || hasPending)) {
m.spinner = (m.spinner + 1) % len(spinnerFrames) m.spinner = (m.spinner + 1) % len(spinnerFrames)
// Update delete progress status
if m.deleting && m.deleteCount != nil { if m.deleting && m.deleteCount != nil {
count := atomic.LoadInt64(m.deleteCount) count := atomic.LoadInt64(m.deleteCount)
if count > 0 { if count > 0 {
@@ -517,33 +513,56 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Handle delete confirmation // Delete confirm flow.
if m.deleteConfirm { if m.deleteConfirm {
switch msg.String() { switch msg.String() {
case "delete", "backspace": case "delete", "backspace":
// Confirm delete - start async deletion m.deleteConfirm = false
if m.deleteTarget != nil { m.deleting = true
m.deleteConfirm = false var deleteCount int64
m.deleting = true m.deleteCount = &deleteCount
var deleteCount int64
m.deleteCount = &deleteCount // Collect paths (safer than indices).
targetPath := m.deleteTarget.Path var pathsToDelete []string
targetName := m.deleteTarget.Name if m.showLargeFiles {
m.deleteTarget = nil if len(m.largeMultiSelected) > 0 {
m.status = fmt.Sprintf("Deleting %s...", targetName) for path := range m.largeMultiSelected {
pathsToDelete = append(pathsToDelete, path)
}
} else if m.deleteTarget != nil {
pathsToDelete = append(pathsToDelete, m.deleteTarget.Path)
}
} else {
if len(m.multiSelected) > 0 {
for path := range m.multiSelected {
pathsToDelete = append(pathsToDelete, path)
}
} else if m.deleteTarget != nil {
pathsToDelete = append(pathsToDelete, m.deleteTarget.Path)
}
}
m.deleteTarget = nil
if len(pathsToDelete) == 0 {
m.deleting = false
m.status = "Nothing to delete"
return m, nil
}
if len(pathsToDelete) == 1 {
targetPath := pathsToDelete[0]
m.status = fmt.Sprintf("Deleting %s...", filepath.Base(targetPath))
return m, tea.Batch(deletePathCmd(targetPath, m.deleteCount), tickCmd()) return m, tea.Batch(deletePathCmd(targetPath, m.deleteCount), tickCmd())
} }
m.deleteConfirm = false
m.deleteTarget = nil m.status = fmt.Sprintf("Deleting %d items...", len(pathsToDelete))
return m, nil return m, tea.Batch(deleteMultiplePathsCmd(pathsToDelete, m.deleteCount), tickCmd())
case "esc", "q": case "esc", "q":
// Cancel delete with ESC or Q
m.status = "Cancelled" m.status = "Cancelled"
m.deleteConfirm = false m.deleteConfirm = false
m.deleteTarget = nil m.deleteTarget = nil
return m, nil return m, nil
default: default:
// Ignore other keys - keep showing confirmation
return m, nil return m, nil
} }
} }
@@ -598,7 +617,6 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if len(m.history) == 0 { if len(m.history) == 0 {
// Return to overview if at top level
if !m.inOverviewMode() { if !m.inOverviewMode() {
return m, m.switchToOverviewMode() return m, m.switchToOverviewMode()
} }
@@ -613,7 +631,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.largeOffset = last.LargeOffset m.largeOffset = last.LargeOffset
m.isOverview = last.IsOverview m.isOverview = last.IsOverview
if last.Dirty { if last.Dirty {
// If returning to overview mode, refresh overview entries instead of scanning // On overview return, refresh cached entries.
if last.IsOverview { if last.IsOverview {
m.hydrateOverviewEntries() m.hydrateOverviewEntries()
m.totalSize = sumKnownEntrySizes(m.entries) m.totalSize = sumKnownEntrySizes(m.entries)
@@ -646,13 +664,14 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.scanning = false m.scanning = false
return m, nil return m, nil
case "r": case "r":
m.multiSelected = make(map[string]bool)
m.largeMultiSelected = make(map[string]bool)
if m.inOverviewMode() { if m.inOverviewMode() {
// In overview mode, clear cache and re-scan known entries
m.overviewSizeCache = make(map[string]int64) m.overviewSizeCache = make(map[string]int64)
m.overviewScanningSet = make(map[string]bool) m.overviewScanningSet = make(map[string]bool)
m.hydrateOverviewEntries() // Reset sizes to pending m.hydrateOverviewEntries() // Reset sizes to pending
// Reset all entries to pending state for visual feedback
for i := range m.entries { for i := range m.entries {
m.entries[i].Size = -1 m.entries[i].Size = -1
} }
@@ -663,11 +682,9 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, tea.Batch(m.scheduleOverviewScans(), tickCmd()) return m, tea.Batch(m.scheduleOverviewScans(), tickCmd())
} }
// Normal mode: Invalidate cache before rescanning
invalidateCache(m.path) invalidateCache(m.path)
m.status = "Refreshing..." m.status = "Refreshing..."
m.scanning = true m.scanning = true
// Reset scan counters for refresh
atomic.StoreInt64(m.filesScanned, 0) atomic.StoreInt64(m.filesScanned, 0)
atomic.StoreInt64(m.dirsScanned, 0) atomic.StoreInt64(m.dirsScanned, 0)
atomic.StoreInt64(m.bytesScanned, 0) atomic.StoreInt64(m.bytesScanned, 0)
@@ -676,19 +693,63 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
return m, tea.Batch(m.scanCmd(m.path), tickCmd()) return m, tea.Batch(m.scanCmd(m.path), tickCmd())
case "t", "T": case "t", "T":
// Don't allow switching to large files view in overview mode
if !m.inOverviewMode() { if !m.inOverviewMode() {
m.showLargeFiles = !m.showLargeFiles m.showLargeFiles = !m.showLargeFiles
if m.showLargeFiles { if m.showLargeFiles {
m.largeSelected = 0 m.largeSelected = 0
m.largeOffset = 0 m.largeOffset = 0
m.largeMultiSelected = make(map[string]bool)
} else {
m.multiSelected = make(map[string]bool)
} }
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
} }
case "o": case "o":
// Open selected entry // Open selected entries (multi-select aware).
const maxBatchOpen = 20
if m.showLargeFiles { if m.showLargeFiles {
if len(m.largeFiles) > 0 { if len(m.largeFiles) > 0 {
selected := m.largeFiles[m.largeSelected] if len(m.largeMultiSelected) > 0 {
count := len(m.largeMultiSelected)
if count > maxBatchOpen {
m.status = fmt.Sprintf("Too many items to open (max %d, selected %d)", maxBatchOpen, count)
return m, nil
}
for path := range m.largeMultiSelected {
go func(p string) {
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
defer cancel()
_ = exec.CommandContext(ctx, "open", p).Run()
}(path)
}
m.status = fmt.Sprintf("Opening %d items...", count)
} else {
selected := m.largeFiles[m.largeSelected]
go func(path string) {
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
defer cancel()
_ = exec.CommandContext(ctx, "open", path).Run()
}(selected.Path)
m.status = fmt.Sprintf("Opening %s...", selected.Name)
}
}
} else if len(m.entries) > 0 {
if len(m.multiSelected) > 0 {
count := len(m.multiSelected)
if count > maxBatchOpen {
m.status = fmt.Sprintf("Too many items to open (max %d, selected %d)", maxBatchOpen, count)
return m, nil
}
for path := range m.multiSelected {
go func(p string) {
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
defer cancel()
_ = exec.CommandContext(ctx, "open", p).Run()
}(path)
}
m.status = fmt.Sprintf("Opening %d items...", count)
} else {
selected := m.entries[m.selected]
go func(path string) { go func(path string) {
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
defer cancel() defer cancel()
@@ -696,20 +757,53 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}(selected.Path) }(selected.Path)
m.status = fmt.Sprintf("Opening %s...", selected.Name) m.status = fmt.Sprintf("Opening %s...", selected.Name)
} }
} else if len(m.entries) > 0 {
selected := m.entries[m.selected]
go func(path string) {
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
defer cancel()
_ = exec.CommandContext(ctx, "open", path).Run()
}(selected.Path)
m.status = fmt.Sprintf("Opening %s...", selected.Name)
} }
case "f", "F": case "f", "F":
// Reveal selected entry in Finder // Reveal in Finder (multi-select aware).
const maxBatchReveal = 20
if m.showLargeFiles { if m.showLargeFiles {
if len(m.largeFiles) > 0 { if len(m.largeFiles) > 0 {
selected := m.largeFiles[m.largeSelected] if len(m.largeMultiSelected) > 0 {
count := len(m.largeMultiSelected)
if count > maxBatchReveal {
m.status = fmt.Sprintf("Too many items to reveal (max %d, selected %d)", maxBatchReveal, count)
return m, nil
}
for path := range m.largeMultiSelected {
go func(p string) {
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
defer cancel()
_ = exec.CommandContext(ctx, "open", "-R", p).Run()
}(path)
}
m.status = fmt.Sprintf("Showing %d items in Finder...", count)
} else {
selected := m.largeFiles[m.largeSelected]
go func(path string) {
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
defer cancel()
_ = exec.CommandContext(ctx, "open", "-R", path).Run()
}(selected.Path)
m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name)
}
}
} else if len(m.entries) > 0 {
if len(m.multiSelected) > 0 {
count := len(m.multiSelected)
if count > maxBatchReveal {
m.status = fmt.Sprintf("Too many items to reveal (max %d, selected %d)", maxBatchReveal, count)
return m, nil
}
for path := range m.multiSelected {
go func(p string) {
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
defer cancel()
_ = exec.CommandContext(ctx, "open", "-R", p).Run()
}(path)
}
m.status = fmt.Sprintf("Showing %d items in Finder...", count)
} else {
selected := m.entries[m.selected]
go func(path string) { go func(path string) {
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
defer cancel() defer cancel()
@@ -717,32 +811,110 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}(selected.Path) }(selected.Path)
m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name)
} }
} else if len(m.entries) > 0 { }
selected := m.entries[m.selected] case " ":
go func(path string) { // Toggle multi-select (paths as keys).
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) if m.showLargeFiles {
defer cancel() if len(m.largeFiles) > 0 && m.largeSelected < len(m.largeFiles) {
_ = exec.CommandContext(ctx, "open", "-R", path).Run() if m.largeMultiSelected == nil {
}(selected.Path) m.largeMultiSelected = make(map[string]bool)
m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) }
selectedPath := m.largeFiles[m.largeSelected].Path
if m.largeMultiSelected[selectedPath] {
delete(m.largeMultiSelected, selectedPath)
} else {
m.largeMultiSelected[selectedPath] = true
}
count := len(m.largeMultiSelected)
if count > 0 {
var totalSize int64
for path := range m.largeMultiSelected {
for _, file := range m.largeFiles {
if file.Path == path {
totalSize += file.Size
break
}
}
}
m.status = fmt.Sprintf("%d selected (%s)", count, humanizeBytes(totalSize))
} else {
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
}
}
} else if len(m.entries) > 0 && !m.inOverviewMode() && m.selected < len(m.entries) {
if m.multiSelected == nil {
m.multiSelected = make(map[string]bool)
}
selectedPath := m.entries[m.selected].Path
if m.multiSelected[selectedPath] {
delete(m.multiSelected, selectedPath)
} else {
m.multiSelected[selectedPath] = true
}
count := len(m.multiSelected)
if count > 0 {
var totalSize int64
for path := range m.multiSelected {
for _, entry := range m.entries {
if entry.Path == path {
totalSize += entry.Size
break
}
}
}
m.status = fmt.Sprintf("%d selected (%s)", count, humanizeBytes(totalSize))
} else {
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
}
} }
case "delete", "backspace": case "delete", "backspace":
// Delete selected file or directory
if m.showLargeFiles { if m.showLargeFiles {
if len(m.largeFiles) > 0 { if len(m.largeFiles) > 0 {
selected := m.largeFiles[m.largeSelected] if len(m.largeMultiSelected) > 0 {
m.deleteConfirm = true m.deleteConfirm = true
m.deleteTarget = &dirEntry{ for path := range m.largeMultiSelected {
Name: selected.Name, for _, file := range m.largeFiles {
Path: selected.Path, if file.Path == path {
Size: selected.Size, m.deleteTarget = &dirEntry{
IsDir: false, Name: file.Name,
Path: file.Path,
Size: file.Size,
IsDir: false,
}
break
}
}
break // Only need first one for display
}
} else if m.largeSelected < len(m.largeFiles) {
selected := m.largeFiles[m.largeSelected]
m.deleteConfirm = true
m.deleteTarget = &dirEntry{
Name: selected.Name,
Path: selected.Path,
Size: selected.Size,
IsDir: false,
}
} }
} }
} else if len(m.entries) > 0 && !m.inOverviewMode() { } else if len(m.entries) > 0 && !m.inOverviewMode() {
selected := m.entries[m.selected] if len(m.multiSelected) > 0 {
m.deleteConfirm = true m.deleteConfirm = true
m.deleteTarget = &selected for path := range m.multiSelected {
// Resolve entry by path.
for i := range m.entries {
if m.entries[i].Path == path {
m.deleteTarget = &m.entries[i]
break
}
}
break // Only need first one for display
}
} else if m.selected < len(m.entries) {
selected := m.entries[m.selected]
m.deleteConfirm = true
m.deleteTarget = &selected
}
} }
} }
return m, nil return m, nil
@@ -766,7 +938,6 @@ func (m *model) switchToOverviewMode() tea.Cmd {
m.status = "Ready" m.status = "Ready"
return nil return nil
} }
// Start tick to animate spinner while scanning
return tea.Batch(cmd, tickCmd()) return tea.Batch(cmd, tickCmd())
} }
@@ -776,7 +947,6 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
} }
selected := m.entries[m.selected] selected := m.entries[m.selected]
if selected.IsDir { if selected.IsDir {
// Always save current state to history (including overview mode)
m.history = append(m.history, snapshotFromModel(m)) m.history = append(m.history, snapshotFromModel(m))
m.path = selected.Path m.path = selected.Path
m.selected = 0 m.selected = 0
@@ -784,8 +954,9 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
m.status = "Scanning..." m.status = "Scanning..."
m.scanning = true m.scanning = true
m.isOverview = false m.isOverview = false
m.multiSelected = make(map[string]bool)
m.largeMultiSelected = make(map[string]bool)
// Reset scan counters for new scan
atomic.StoreInt64(m.filesScanned, 0) atomic.StoreInt64(m.filesScanned, 0)
atomic.StoreInt64(m.dirsScanned, 0) atomic.StoreInt64(m.dirsScanned, 0)
atomic.StoreInt64(m.bytesScanned, 0) atomic.StoreInt64(m.bytesScanned, 0)

View File

@@ -31,16 +31,14 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
var total int64 var total int64
// Use heaps to track Top N items, drastically reducing memory usage // Keep Top N heaps.
// for directories with millions of files
entriesHeap := &entryHeap{} entriesHeap := &entryHeap{}
heap.Init(entriesHeap) heap.Init(entriesHeap)
largeFilesHeap := &largeFileHeap{} largeFilesHeap := &largeFileHeap{}
heap.Init(largeFilesHeap) heap.Init(largeFilesHeap)
// Use worker pool for concurrent directory scanning // Worker pool sized for I/O-bound scanning.
// For I/O-bound operations, use more workers than CPU count
numWorkers := runtime.NumCPU() * cpuMultiplier numWorkers := runtime.NumCPU() * cpuMultiplier
if numWorkers < minWorkers { if numWorkers < minWorkers {
numWorkers = minWorkers numWorkers = minWorkers
@@ -57,17 +55,15 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
sem := make(chan struct{}, numWorkers) sem := make(chan struct{}, numWorkers)
var wg sync.WaitGroup var wg sync.WaitGroup
// Use channels to collect results without lock contention // Collect results via channels.
entryChan := make(chan dirEntry, len(children)) entryChan := make(chan dirEntry, len(children))
largeFileChan := make(chan fileEntry, maxLargeFiles*2) largeFileChan := make(chan fileEntry, maxLargeFiles*2)
// Start goroutines to collect from channels into heaps
var collectorWg sync.WaitGroup var collectorWg sync.WaitGroup
collectorWg.Add(2) collectorWg.Add(2)
go func() { go func() {
defer collectorWg.Done() defer collectorWg.Done()
for entry := range entryChan { for entry := range entryChan {
// Maintain Top N Heap for entries
if entriesHeap.Len() < maxEntries { if entriesHeap.Len() < maxEntries {
heap.Push(entriesHeap, entry) heap.Push(entriesHeap, entry)
} else if entry.Size > (*entriesHeap)[0].Size { } else if entry.Size > (*entriesHeap)[0].Size {
@@ -79,7 +75,6 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
go func() { go func() {
defer collectorWg.Done() defer collectorWg.Done()
for file := range largeFileChan { for file := range largeFileChan {
// Maintain Top N Heap for large files
if largeFilesHeap.Len() < maxLargeFiles { if largeFilesHeap.Len() < maxLargeFiles {
heap.Push(largeFilesHeap, file) heap.Push(largeFilesHeap, file)
} else if file.Size > (*largeFilesHeap)[0].Size { } else if file.Size > (*largeFilesHeap)[0].Size {
@@ -90,24 +85,21 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
}() }()
isRootDir := root == "/" isRootDir := root == "/"
home := os.Getenv("HOME")
isHomeDir := home != "" && root == home
for _, child := range children { for _, child := range children {
fullPath := filepath.Join(root, child.Name()) fullPath := filepath.Join(root, child.Name())
// Skip symlinks to avoid following them into unexpected locations // Skip symlinks to avoid following unexpected targets.
// Use Type() instead of IsDir() to check without following symlinks
if child.Type()&fs.ModeSymlink != 0 { if child.Type()&fs.ModeSymlink != 0 {
// For symlinks, check if they point to a directory
targetInfo, err := os.Stat(fullPath) targetInfo, err := os.Stat(fullPath)
isDir := false isDir := false
if err == nil && targetInfo.IsDir() { if err == nil && targetInfo.IsDir() {
isDir = true isDir = true
} }
// Get symlink size (we don't effectively count the target size towards parent to avoid double counting, // Count link size only to avoid double-counting targets.
// or we just count the link size itself. Existing logic counts 'size' via getActualFileSize on the link info).
// Ideally we just want navigation.
// Re-fetching info for link itself if needed, but child.Info() does that.
info, err := child.Info() info, err := child.Info()
if err != nil { if err != nil {
continue continue
@@ -116,27 +108,56 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
atomic.AddInt64(&total, size) atomic.AddInt64(&total, size)
entryChan <- dirEntry{ entryChan <- dirEntry{
Name: child.Name() + " →", // Add arrow to indicate symlink Name: child.Name() + " →",
Path: fullPath, Path: fullPath,
Size: size, Size: size,
IsDir: isDir, // Allow navigation if target is directory IsDir: isDir,
LastAccess: getLastAccessTimeFromInfo(info), LastAccess: getLastAccessTimeFromInfo(info),
} }
continue continue
} }
if child.IsDir() { if child.IsDir() {
// Check if directory should be skipped based on user configuration
if defaultSkipDirs[child.Name()] { if defaultSkipDirs[child.Name()] {
continue continue
} }
// In root directory, skip system directories completely // Skip system dirs at root.
if isRootDir && skipSystemDirs[child.Name()] { if isRootDir && skipSystemDirs[child.Name()] {
continue continue
} }
// For folded directories, calculate size quickly without expanding // ~/Library is scanned separately; reuse cache when possible.
if isHomeDir && child.Name() == "Library" {
wg.Add(1)
go func(name, path string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
var size int64
if cached, err := loadStoredOverviewSize(path); err == nil && cached > 0 {
size = cached
} else if cached, err := loadCacheFromDisk(path); err == nil {
size = cached.TotalSize
} else {
size = calculateDirSizeConcurrent(path, largeFileChan, filesScanned, dirsScanned, bytesScanned, currentPath)
}
atomic.AddInt64(&total, size)
atomic.AddInt64(dirsScanned, 1)
entryChan <- dirEntry{
Name: name,
Path: path,
Size: size,
IsDir: true,
LastAccess: time.Time{},
}
}(child.Name(), fullPath)
continue
}
// Folded dirs: fast size without expanding.
if shouldFoldDirWithPath(child.Name(), fullPath) { if shouldFoldDirWithPath(child.Name(), fullPath) {
wg.Add(1) wg.Add(1)
go func(name, path string) { go func(name, path string) {
@@ -144,10 +165,8 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
sem <- struct{}{} sem <- struct{}{}
defer func() { <-sem }() defer func() { <-sem }()
// Try du command first for folded dirs (much faster)
size, err := getDirectorySizeFromDu(path) size, err := getDirectorySizeFromDu(path)
if err != nil || size <= 0 { if err != nil || size <= 0 {
// Fallback to concurrent walk if du fails
size = calculateDirSizeFast(path, filesScanned, dirsScanned, bytesScanned, currentPath) size = calculateDirSizeFast(path, filesScanned, dirsScanned, bytesScanned, currentPath)
} }
atomic.AddInt64(&total, size) atomic.AddInt64(&total, size)
@@ -158,13 +177,12 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
Path: path, Path: path,
Size: size, Size: size,
IsDir: true, IsDir: true,
LastAccess: time.Time{}, // Lazy load when displayed LastAccess: time.Time{},
} }
}(child.Name(), fullPath) }(child.Name(), fullPath)
continue continue
} }
// Normal directory: full scan with detail
wg.Add(1) wg.Add(1)
go func(name, path string) { go func(name, path string) {
defer wg.Done() defer wg.Done()
@@ -180,7 +198,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
Path: path, Path: path,
Size: size, Size: size,
IsDir: true, IsDir: true,
LastAccess: time.Time{}, // Lazy load when displayed LastAccess: time.Time{},
} }
}(child.Name(), fullPath) }(child.Name(), fullPath)
continue continue
@@ -190,7 +208,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
if err != nil { if err != nil {
continue continue
} }
// Get actual disk usage for sparse files and cloud files // Actual disk usage for sparse/cloud files.
size := getActualFileSize(fullPath, info) size := getActualFileSize(fullPath, info)
atomic.AddInt64(&total, size) atomic.AddInt64(&total, size)
atomic.AddInt64(filesScanned, 1) atomic.AddInt64(filesScanned, 1)
@@ -203,7 +221,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
IsDir: false, IsDir: false,
LastAccess: getLastAccessTimeFromInfo(info), LastAccess: getLastAccessTimeFromInfo(info),
} }
// Only track large files that are not code/text files // Track large files only.
if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize { if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize {
largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size} largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
} }
@@ -211,12 +229,12 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
wg.Wait() wg.Wait()
// Close channels and wait for collectors to finish // Close channels and wait for collectors.
close(entryChan) close(entryChan)
close(largeFileChan) close(largeFileChan)
collectorWg.Wait() collectorWg.Wait()
// Convert Heaps to sorted slices (Descending order) // Convert heaps to sorted slices (descending).
entries := make([]dirEntry, entriesHeap.Len()) entries := make([]dirEntry, entriesHeap.Len())
for i := len(entries) - 1; i >= 0; i-- { for i := len(entries) - 1; i >= 0; i-- {
entries[i] = heap.Pop(entriesHeap).(dirEntry) entries[i] = heap.Pop(entriesHeap).(dirEntry)
@@ -227,20 +245,11 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
largeFiles[i] = heap.Pop(largeFilesHeap).(fileEntry) largeFiles[i] = heap.Pop(largeFilesHeap).(fileEntry)
} }
// Try to use Spotlight (mdfind) for faster large file discovery // Use Spotlight for large files when available.
// This is a performance optimization that gracefully falls back to scan results
// if Spotlight is unavailable or fails. The fallback is intentionally silent
// because users only care about correct results, not the method used.
if spotlightFiles := findLargeFilesWithSpotlight(root, minLargeFileSize); len(spotlightFiles) > 0 { if spotlightFiles := findLargeFilesWithSpotlight(root, minLargeFileSize); len(spotlightFiles) > 0 {
// Spotlight results are already sorted top N
// Use them in place of scanned large files
largeFiles = spotlightFiles largeFiles = spotlightFiles
} }
// Double check sorting consistency (Spotlight returns sorted, but heap pop handles scan results)
// If needed, we could re-sort largeFiles, but heap pop ensures ascending, and we filled reverse, so it's Descending.
// Spotlight returns Descending. So no extra sort needed for either.
return scanResult{ return scanResult{
Entries: entries, Entries: entries,
LargeFiles: largeFiles, LargeFiles: largeFiles,
@@ -249,21 +258,16 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
} }
func shouldFoldDirWithPath(name, path string) bool { func shouldFoldDirWithPath(name, path string) bool {
// Check basic fold list first
if foldDirs[name] { if foldDirs[name] {
return true return true
} }
// Special case: npm cache directories - fold all subdirectories // Handle npm cache structure.
// This includes: .npm/_quick/*, .npm/_cacache/*, .npm/a-z/*, .tnpm/*
if strings.Contains(path, "/.npm/") || strings.Contains(path, "/.tnpm/") { if strings.Contains(path, "/.npm/") || strings.Contains(path, "/.tnpm/") {
// Get the parent directory name
parent := filepath.Base(filepath.Dir(path)) parent := filepath.Base(filepath.Dir(path))
// If parent is a cache folder (_quick, _cacache, etc) or npm dir itself, fold it
if parent == ".npm" || parent == ".tnpm" || strings.HasPrefix(parent, "_") { if parent == ".npm" || parent == ".tnpm" || strings.HasPrefix(parent, "_") {
return true return true
} }
// Also fold single-letter subdirectories (npm cache structure like .npm/a/, .npm/b/)
if len(name) == 1 { if len(name) == 1 {
return true return true
} }
@@ -277,17 +281,14 @@ func shouldSkipFileForLargeTracking(path string) bool {
return skipExtensions[ext] return skipExtensions[ext]
} }
// calculateDirSizeFast performs concurrent directory size calculation using os.ReadDir // calculateDirSizeFast performs concurrent dir sizing using os.ReadDir.
// This is a faster fallback than filepath.WalkDir when du fails
func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 { func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 {
var total int64 var total int64
var wg sync.WaitGroup var wg sync.WaitGroup
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
// Limit total concurrency for this walk
concurrency := runtime.NumCPU() * 4 concurrency := runtime.NumCPU() * 4
if concurrency > 64 { if concurrency > 64 {
concurrency = 64 concurrency = 64
@@ -315,19 +316,16 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() { if entry.IsDir() {
// Directories: recurse concurrently
wg.Add(1) wg.Add(1)
// Capture loop variable
subDir := filepath.Join(dirPath, entry.Name()) subDir := filepath.Join(dirPath, entry.Name())
go func(p string) { go func(p string) {
defer wg.Done() defer wg.Done()
sem <- struct{}{} // Acquire token sem <- struct{}{}
defer func() { <-sem }() // Release token defer func() { <-sem }()
walk(p) walk(p)
}(subDir) }(subDir)
atomic.AddInt64(dirsScanned, 1) atomic.AddInt64(dirsScanned, 1)
} else { } else {
// Files: process immediately
info, err := entry.Info() info, err := entry.Info()
if err == nil { if err == nil {
size := getActualFileSize(filepath.Join(dirPath, entry.Name()), info) size := getActualFileSize(filepath.Join(dirPath, entry.Name()), info)
@@ -352,9 +350,8 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *
return total return total
} }
// Use Spotlight (mdfind) to quickly find large files in a directory // Use Spotlight (mdfind) to quickly find large files.
func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry { func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
// mdfind query: files >= minSize in the specified directory
query := fmt.Sprintf("kMDItemFSSize >= %d", minSize) query := fmt.Sprintf("kMDItemFSSize >= %d", minSize)
ctx, cancel := context.WithTimeout(context.Background(), mdlsTimeout) ctx, cancel := context.WithTimeout(context.Background(), mdlsTimeout)
@@ -363,7 +360,6 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
cmd := exec.CommandContext(ctx, "mdfind", "-onlyin", root, query) cmd := exec.CommandContext(ctx, "mdfind", "-onlyin", root, query)
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
// Fallback: mdfind not available or failed
return nil return nil
} }
@@ -375,28 +371,26 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
continue continue
} }
// Filter out code files first (cheapest check, no I/O) // Filter code files first (cheap).
if shouldSkipFileForLargeTracking(line) { if shouldSkipFileForLargeTracking(line) {
continue continue
} }
// Filter out files in folded directories (cheap string check) // Filter folded directories (cheap string check).
if isInFoldedDir(line) { if isInFoldedDir(line) {
continue continue
} }
// Use Lstat instead of Stat (faster, doesn't follow symlinks)
info, err := os.Lstat(line) info, err := os.Lstat(line)
if err != nil { if err != nil {
continue continue
} }
// Skip if it's a directory or symlink
if info.IsDir() || info.Mode()&os.ModeSymlink != 0 { if info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
continue continue
} }
// Get actual disk usage for sparse files and cloud files // Actual disk usage for sparse/cloud files.
actualSize := getActualFileSize(line, info) actualSize := getActualFileSize(line, info)
files = append(files, fileEntry{ files = append(files, fileEntry{
Name: filepath.Base(line), Name: filepath.Base(line),
@@ -405,12 +399,11 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
}) })
} }
// Sort by size (descending) // Sort by size (descending).
sort.Slice(files, func(i, j int) bool { sort.Slice(files, func(i, j int) bool {
return files[i].Size > files[j].Size return files[i].Size > files[j].Size
}) })
// Return top N
if len(files) > maxLargeFiles { if len(files) > maxLargeFiles {
files = files[:maxLargeFiles] files = files[:maxLargeFiles]
} }
@@ -418,9 +411,8 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
return files return files
} }
// isInFoldedDir checks if a path is inside a folded directory (optimized) // isInFoldedDir checks if a path is inside a folded directory.
func isInFoldedDir(path string) bool { func isInFoldedDir(path string) bool {
// Split path into components for faster checking
parts := strings.Split(path, string(os.PathSeparator)) parts := strings.Split(path, string(os.PathSeparator))
for _, part := range parts { for _, part := range parts {
if foldDirs[part] { if foldDirs[part] {
@@ -431,7 +423,6 @@ func isInFoldedDir(path string) bool {
} }
func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 { func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 {
// Read immediate children
children, err := os.ReadDir(root) children, err := os.ReadDir(root)
if err != nil { if err != nil {
return 0 return 0
@@ -440,7 +431,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
var total int64 var total int64
var wg sync.WaitGroup var wg sync.WaitGroup
// Limit concurrent subdirectory scans to avoid too many goroutines // Limit concurrent subdirectory scans.
maxConcurrent := runtime.NumCPU() * 2 maxConcurrent := runtime.NumCPU() * 2
if maxConcurrent > maxDirWorkers { if maxConcurrent > maxDirWorkers {
maxConcurrent = maxDirWorkers maxConcurrent = maxDirWorkers
@@ -450,9 +441,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
for _, child := range children { for _, child := range children {
fullPath := filepath.Join(root, child.Name()) fullPath := filepath.Join(root, child.Name())
// Skip symlinks to avoid following them into unexpected locations
if child.Type()&fs.ModeSymlink != 0 { if child.Type()&fs.ModeSymlink != 0 {
// For symlinks, just count their size without following
info, err := child.Info() info, err := child.Info()
if err != nil { if err != nil {
continue continue
@@ -465,9 +454,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
} }
if child.IsDir() { if child.IsDir() {
// Check if this is a folded directory
if shouldFoldDirWithPath(child.Name(), fullPath) { if shouldFoldDirWithPath(child.Name(), fullPath) {
// Use du for folded directories (much faster)
wg.Add(1) wg.Add(1)
go func(path string) { go func(path string) {
defer wg.Done() defer wg.Done()
@@ -481,7 +468,6 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
continue continue
} }
// Recursively scan subdirectory in parallel
wg.Add(1) wg.Add(1)
go func(path string) { go func(path string) {
defer wg.Done() defer wg.Done()
@@ -495,7 +481,6 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
continue continue
} }
// Handle files
info, err := child.Info() info, err := child.Info()
if err != nil { if err != nil {
continue continue
@@ -506,12 +491,11 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
atomic.AddInt64(filesScanned, 1) atomic.AddInt64(filesScanned, 1)
atomic.AddInt64(bytesScanned, size) atomic.AddInt64(bytesScanned, size)
// Track large files
if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize { if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize {
largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size} largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
} }
// Update current path occasionally to prevent UI jitter // Update current path occasionally to prevent UI jitter.
if currentPath != nil && atomic.LoadInt64(filesScanned)%int64(batchUpdateSize) == 0 { if currentPath != nil && atomic.LoadInt64(filesScanned)%int64(batchUpdateSize) == 0 {
*currentPath = fullPath *currentPath = fullPath
} }
@@ -522,6 +506,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
} }
// measureOverviewSize calculates the size of a directory using multiple strategies. // measureOverviewSize calculates the size of a directory using multiple strategies.
// When scanning Home, it excludes ~/Library to avoid duplicate counting.
func measureOverviewSize(path string) (int64, error) { func measureOverviewSize(path string) (int64, error) {
if path == "" { if path == "" {
return 0, fmt.Errorf("empty path") return 0, fmt.Errorf("empty path")
@@ -536,16 +521,23 @@ func measureOverviewSize(path string) (int64, error) {
return 0, fmt.Errorf("cannot access path: %v", err) return 0, fmt.Errorf("cannot access path: %v", err)
} }
// Determine if we should exclude ~/Library (when scanning Home)
home := os.Getenv("HOME")
excludePath := ""
if home != "" && path == home {
excludePath = filepath.Join(home, "Library")
}
if cached, err := loadStoredOverviewSize(path); err == nil && cached > 0 { if cached, err := loadStoredOverviewSize(path); err == nil && cached > 0 {
return cached, nil return cached, nil
} }
if duSize, err := getDirectorySizeFromDu(path); err == nil && duSize > 0 { if duSize, err := getDirectorySizeFromDuWithExclude(path, excludePath); err == nil && duSize > 0 {
_ = storeOverviewSize(path, duSize) _ = storeOverviewSize(path, duSize)
return duSize, nil return duSize, nil
} }
if logicalSize, err := getDirectoryLogicalSize(path); err == nil && logicalSize > 0 { if logicalSize, err := getDirectoryLogicalSizeWithExclude(path, excludePath); err == nil && logicalSize > 0 {
_ = storeOverviewSize(path, logicalSize) _ = storeOverviewSize(path, logicalSize)
return logicalSize, nil return logicalSize, nil
} }
@@ -559,38 +551,69 @@ func measureOverviewSize(path string) (int64, error) {
} }
func getDirectorySizeFromDu(path string) (int64, error) { func getDirectorySizeFromDu(path string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), duTimeout) return getDirectorySizeFromDuWithExclude(path, "")
defer cancel()
cmd := exec.CommandContext(ctx, "du", "-sk", path)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return 0, fmt.Errorf("du timeout after %v", duTimeout)
}
if stderr.Len() > 0 {
return 0, fmt.Errorf("du failed: %v (%s)", err, stderr.String())
}
return 0, fmt.Errorf("du failed: %v", err)
}
fields := strings.Fields(stdout.String())
if len(fields) == 0 {
return 0, fmt.Errorf("du output empty")
}
kb, err := strconv.ParseInt(fields[0], 10, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse du output: %v", err)
}
if kb <= 0 {
return 0, fmt.Errorf("du size invalid: %d", kb)
}
return kb * 1024, nil
} }
func getDirectoryLogicalSize(path string) (int64, error) { func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, error) {
runDuSize := func(target string) (int64, error) {
if _, err := os.Stat(target); err != nil {
return 0, err
}
ctx, cancel := context.WithTimeout(context.Background(), duTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "du", "-sk", target)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return 0, fmt.Errorf("du timeout after %v", duTimeout)
}
if stderr.Len() > 0 {
return 0, fmt.Errorf("du failed: %v (%s)", err, stderr.String())
}
return 0, fmt.Errorf("du failed: %v", err)
}
fields := strings.Fields(stdout.String())
if len(fields) == 0 {
return 0, fmt.Errorf("du output empty")
}
kb, err := strconv.ParseInt(fields[0], 10, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse du output: %v", err)
}
if kb <= 0 {
return 0, fmt.Errorf("du size invalid: %d", kb)
}
return kb * 1024, nil
}
// When excluding a path (e.g., ~/Library), subtract only that exact directory instead of ignoring every "Library"
if excludePath != "" {
totalSize, err := runDuSize(path)
if err != nil {
return 0, err
}
excludeSize, err := runDuSize(excludePath)
if err != nil {
if !os.IsNotExist(err) {
return 0, err
}
excludeSize = 0
}
if excludeSize > totalSize {
excludeSize = 0
}
return totalSize - excludeSize, nil
}
return runDuSize(path)
}
func getDirectoryLogicalSizeWithExclude(path string, excludePath string) (int64, error) {
var total int64 var total int64
err := filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error { err := filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
@@ -599,6 +622,10 @@ func getDirectoryLogicalSize(path string) (int64, error) {
} }
return nil return nil
} }
// Skip excluded path
if excludePath != "" && p == excludePath {
return filepath.SkipDir
}
if d.IsDir() { if d.IsDir() {
return nil return nil
} }

View File

@@ -0,0 +1,45 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func writeFileWithSize(t *testing.T, path string, size int) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
content := make([]byte, size)
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestGetDirectoryLogicalSizeWithExclude(t *testing.T) {
base := t.TempDir()
homeFile := filepath.Join(base, "fileA")
libFile := filepath.Join(base, "Library", "fileB")
projectLibFile := filepath.Join(base, "Projects", "Library", "fileC")
writeFileWithSize(t, homeFile, 100)
writeFileWithSize(t, libFile, 200)
writeFileWithSize(t, projectLibFile, 300)
total, err := getDirectoryLogicalSizeWithExclude(base, "")
if err != nil {
t.Fatalf("getDirectoryLogicalSizeWithExclude (no exclude) error: %v", err)
}
if total != 600 {
t.Fatalf("expected total 600 bytes, got %d", total)
}
excluding, err := getDirectoryLogicalSizeWithExclude(base, filepath.Join(base, "Library"))
if err != nil {
t.Fatalf("getDirectoryLogicalSizeWithExclude (exclude Library) error: %v", err)
}
if excluding != 400 {
t.Fatalf("expected 400 bytes when excluding top-level Library, got %d", excluding)
}
}

View File

@@ -8,7 +8,7 @@ import (
"sync/atomic" "sync/atomic"
) )
// View renders the TUI display. // View renders the TUI.
func (m model) View() string { func (m model) View() string {
var b strings.Builder var b strings.Builder
fmt.Fprintln(&b) fmt.Fprintln(&b)
@@ -16,7 +16,6 @@ func (m model) View() string {
if m.inOverviewMode() { if m.inOverviewMode() {
fmt.Fprintf(&b, "%sAnalyze Disk%s\n", colorPurpleBold, colorReset) fmt.Fprintf(&b, "%sAnalyze Disk%s\n", colorPurpleBold, colorReset)
if m.overviewScanning { if m.overviewScanning {
// Check if we're in initial scan (all entries are pending)
allPending := true allPending := true
for _, entry := range m.entries { for _, entry := range m.entries {
if entry.Size >= 0 { if entry.Size >= 0 {
@@ -26,19 +25,16 @@ func (m model) View() string {
} }
if allPending { if allPending {
// Show prominent loading screen for initial scan
fmt.Fprintf(&b, "%s%s%s%s Analyzing disk usage, please wait...%s\n", fmt.Fprintf(&b, "%s%s%s%s Analyzing disk usage, please wait...%s\n",
colorCyan, colorBold, colorCyan, colorBold,
spinnerFrames[m.spinner], spinnerFrames[m.spinner],
colorReset, colorReset) colorReset, colorReset)
return b.String() return b.String()
} else { } else {
// Progressive scanning - show subtle indicator
fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset) fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset)
fmt.Fprintf(&b, "%s%s%s%s Scanning...\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset) fmt.Fprintf(&b, "%s%s%s%s Scanning...\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset)
} }
} else { } else {
// Check if there are still pending items
hasPending := false hasPending := false
for _, entry := range m.entries { for _, entry := range m.entries {
if entry.Size < 0 { if entry.Size < 0 {
@@ -62,7 +58,6 @@ func (m model) View() string {
} }
if m.deleting { if m.deleting {
// Show delete progress
count := int64(0) count := int64(0)
if m.deleteCount != nil { if m.deleteCount != nil {
count = atomic.LoadInt64(m.deleteCount) count = atomic.LoadInt64(m.deleteCount)
@@ -119,25 +114,36 @@ func (m model) View() string {
maxLargeSize = file.Size maxLargeSize = file.Size
} }
} }
nameWidth := calculateNameWidth(m.width)
for idx := start; idx < end; idx++ { for idx := start; idx < end; idx++ {
file := m.largeFiles[idx] file := m.largeFiles[idx]
shortPath := displayPath(file.Path) shortPath := displayPath(file.Path)
shortPath = truncateMiddle(shortPath, 35) shortPath = truncateMiddle(shortPath, nameWidth)
paddedPath := padName(shortPath, 35) paddedPath := padName(shortPath, nameWidth)
entryPrefix := " " entryPrefix := " "
nameColor := "" nameColor := ""
sizeColor := colorGray sizeColor := colorGray
numColor := "" numColor := ""
isMultiSelected := m.largeMultiSelected != nil && m.largeMultiSelected[file.Path]
selectIcon := "○"
if isMultiSelected {
selectIcon = fmt.Sprintf("%s●%s", colorGreen, colorReset)
nameColor = colorGreen
}
if idx == m.largeSelected { if idx == m.largeSelected {
entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset) entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset)
nameColor = colorCyan if !isMultiSelected {
nameColor = colorCyan
}
sizeColor = colorCyan sizeColor = colorCyan
numColor = colorCyan numColor = colorCyan
} }
size := humanizeBytes(file.Size) size := humanizeBytes(file.Size)
bar := coloredProgressBar(file.Size, maxLargeSize, 0) bar := coloredProgressBar(file.Size, maxLargeSize, 0)
fmt.Fprintf(&b, "%s%s%2d.%s %s | 📄 %s%s%s %s%10s%s\n", fmt.Fprintf(&b, "%s%s %s%2d.%s %s | 📄 %s%s%s %s%10s%s\n",
entryPrefix, numColor, idx+1, colorReset, bar, nameColor, paddedPath, colorReset, sizeColor, size, colorReset) entryPrefix, selectIcon, numColor, idx+1, colorReset, bar, nameColor, paddedPath, colorReset, sizeColor, size, colorReset)
} }
} }
} else { } else {
@@ -152,6 +158,8 @@ func (m model) View() string {
} }
} }
totalSize := m.totalSize totalSize := m.totalSize
// Overview paths are short; fixed width keeps layout stable.
nameWidth := 20
for idx, entry := range m.entries { for idx, entry := range m.entries {
icon := "📁" icon := "📁"
sizeVal := entry.Size sizeVal := entry.Size
@@ -188,8 +196,8 @@ func (m model) View() string {
} }
} }
entryPrefix := " " entryPrefix := " "
name := trimName(entry.Name) name := trimNameWithWidth(entry.Name, nameWidth)
paddedName := padName(name, 28) paddedName := padName(name, nameWidth)
nameSegment := fmt.Sprintf("%s %s", icon, paddedName) nameSegment := fmt.Sprintf("%s %s", icon, paddedName)
numColor := "" numColor := ""
percentColor := "" percentColor := ""
@@ -202,12 +210,10 @@ func (m model) View() string {
} }
displayIndex := idx + 1 displayIndex := idx + 1
// Priority: cleanable > unused time
var hintLabel string var hintLabel string
if entry.IsDir && isCleanableDir(entry.Path) { if entry.IsDir && isCleanableDir(entry.Path) {
hintLabel = fmt.Sprintf("%s🧹%s", colorYellow, colorReset) hintLabel = fmt.Sprintf("%s🧹%s", colorYellow, colorReset)
} else { } else {
// For overview mode, get access time on-demand if not set
lastAccess := entry.LastAccess lastAccess := entry.LastAccess
if lastAccess.IsZero() && entry.Path != "" { if lastAccess.IsZero() && entry.Path != "" {
lastAccess = getLastAccessTime(entry.Path) lastAccess = getLastAccessTime(entry.Path)
@@ -228,7 +234,6 @@ func (m model) View() string {
} }
} }
} else { } else {
// Normal mode with sizes and progress bars
maxSize := int64(1) maxSize := int64(1)
for _, entry := range m.entries { for _, entry := range m.entries {
if entry.Size > maxSize { if entry.Size > maxSize {
@@ -237,6 +242,7 @@ func (m model) View() string {
} }
viewport := calculateViewport(m.height, false) viewport := calculateViewport(m.height, false)
nameWidth := calculateNameWidth(m.width)
start := m.offset start := m.offset
if start < 0 { if start < 0 {
start = 0 start = 0
@@ -253,17 +259,14 @@ func (m model) View() string {
icon = "📁" icon = "📁"
} }
size := humanizeBytes(entry.Size) size := humanizeBytes(entry.Size)
name := trimName(entry.Name) name := trimNameWithWidth(entry.Name, nameWidth)
paddedName := padName(name, 28) paddedName := padName(name, nameWidth)
// Calculate percentage
percent := float64(entry.Size) / float64(m.totalSize) * 100 percent := float64(entry.Size) / float64(m.totalSize) * 100
percentStr := fmt.Sprintf("%5.1f%%", percent) percentStr := fmt.Sprintf("%5.1f%%", percent)
// Get colored progress bar
bar := coloredProgressBar(entry.Size, maxSize, percent) bar := coloredProgressBar(entry.Size, maxSize, percent)
// Color the size based on magnitude
var sizeColor string var sizeColor string
if percent >= 50 { if percent >= 50 {
sizeColor = colorRed sizeColor = colorRed
@@ -275,14 +278,26 @@ func (m model) View() string {
sizeColor = colorGray sizeColor = colorGray
} }
// Keep chart columns aligned even when arrow is shown isMultiSelected := m.multiSelected != nil && m.multiSelected[entry.Path]
selectIcon := "○"
nameColor := ""
if isMultiSelected {
selectIcon = fmt.Sprintf("%s●%s", colorGreen, colorReset)
nameColor = colorGreen
}
entryPrefix := " " entryPrefix := " "
nameSegment := fmt.Sprintf("%s %s", icon, paddedName) nameSegment := fmt.Sprintf("%s %s", icon, paddedName)
if nameColor != "" {
nameSegment = fmt.Sprintf("%s%s %s%s", nameColor, icon, paddedName, colorReset)
}
numColor := "" numColor := ""
percentColor := "" percentColor := ""
if idx == m.selected { if idx == m.selected {
entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset) entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset)
nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset) if !isMultiSelected {
nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset)
}
numColor = colorCyan numColor = colorCyan
percentColor = colorCyan percentColor = colorCyan
sizeColor = colorCyan sizeColor = colorCyan
@@ -290,12 +305,10 @@ func (m model) View() string {
displayIndex := idx + 1 displayIndex := idx + 1
// Priority: cleanable > unused time
var hintLabel string var hintLabel string
if entry.IsDir && isCleanableDir(entry.Path) { if entry.IsDir && isCleanableDir(entry.Path) {
hintLabel = fmt.Sprintf("%s🧹%s", colorYellow, colorReset) hintLabel = fmt.Sprintf("%s🧹%s", colorYellow, colorReset)
} else { } else {
// Get access time on-demand if not set
lastAccess := entry.LastAccess lastAccess := entry.LastAccess
if lastAccess.IsZero() && entry.Path != "" { if lastAccess.IsZero() && entry.Path != "" {
lastAccess = getLastAccessTime(entry.Path) lastAccess = getLastAccessTime(entry.Path)
@@ -306,12 +319,12 @@ func (m model) View() string {
} }
if hintLabel == "" { if hintLabel == "" {
fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s\n", fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s | %s %s%10s%s\n",
entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, entryPrefix, selectIcon, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset,
nameSegment, sizeColor, size, colorReset) nameSegment, sizeColor, size, colorReset)
} else { } else {
fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s %s\n", fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s | %s %s%10s%s %s\n",
entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, entryPrefix, selectIcon, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset,
nameSegment, sizeColor, size, colorReset, hintLabel) nameSegment, sizeColor, size, colorReset, hintLabel)
} }
} }
@@ -321,53 +334,94 @@ func (m model) View() string {
fmt.Fprintln(&b) fmt.Fprintln(&b)
if m.inOverviewMode() { if m.inOverviewMode() {
// Show ← Back if there's history (entered from a parent directory)
if len(m.history) > 0 { if len(m.history) > 0 {
fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ← Back | Q Quit%s\n", colorGray, colorReset) fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ← Back | Q Quit%s\n", colorGray, colorReset)
} else { } else {
fmt.Fprintf(&b, "%s↑↓→ | Enter | R Refresh | O Open | F File | Q Quit%s\n", colorGray, colorReset) fmt.Fprintf(&b, "%s↑↓→ | Enter | R Refresh | O Open | F File | Q Quit%s\n", colorGray, colorReset)
} }
} else if m.showLargeFiles { } else if m.showLargeFiles {
fmt.Fprintf(&b, "%s↑↓← | R Refresh | O Open | F File | ⌫ Del | ← Back | Q Quit%s\n", colorGray, colorReset) selectCount := len(m.largeMultiSelected)
if selectCount > 0 {
fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | F File | ⌫ Del(%d) | ← Back | Q Quit%s\n", colorGray, selectCount, colorReset)
} else {
fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | F File | ⌫ Del | ← Back | Q Quit%s\n", colorGray, colorReset)
}
} else { } else {
largeFileCount := len(m.largeFiles) largeFileCount := len(m.largeFiles)
if largeFileCount > 0 { selectCount := len(m.multiSelected)
fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ⌫ Del | T Top(%d) | Q Quit%s\n", colorGray, largeFileCount, colorReset) if selectCount > 0 {
if largeFileCount > 0 {
fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del(%d) | T Top(%d) | Q Quit%s\n", colorGray, selectCount, largeFileCount, colorReset)
} else {
fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del(%d) | Q Quit%s\n", colorGray, selectCount, colorReset)
}
} else { } else {
fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ⌫ Del | Q Quit%s\n", colorGray, colorReset) if largeFileCount > 0 {
fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del | T Top(%d) | Q Quit%s\n", colorGray, largeFileCount, colorReset)
} else {
fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del | Q Quit%s\n", colorGray, colorReset)
}
} }
} }
if m.deleteConfirm && m.deleteTarget != nil { if m.deleteConfirm && m.deleteTarget != nil {
fmt.Fprintln(&b) fmt.Fprintln(&b)
fmt.Fprintf(&b, "%sDelete:%s %s (%s) %sPress ⌫ again | ESC cancel%s\n", var deleteCount int
colorRed, colorReset, var totalDeleteSize int64
m.deleteTarget.Name, humanizeBytes(m.deleteTarget.Size), if m.showLargeFiles && len(m.largeMultiSelected) > 0 {
colorGray, colorReset) deleteCount = len(m.largeMultiSelected)
for path := range m.largeMultiSelected {
for _, file := range m.largeFiles {
if file.Path == path {
totalDeleteSize += file.Size
break
}
}
}
} else if !m.showLargeFiles && len(m.multiSelected) > 0 {
deleteCount = len(m.multiSelected)
for path := range m.multiSelected {
for _, entry := range m.entries {
if entry.Path == path {
totalDeleteSize += entry.Size
break
}
}
}
}
if deleteCount > 1 {
fmt.Fprintf(&b, "%sDelete:%s %d items (%s) %sPress ⌫ again | ESC cancel%s\n",
colorRed, colorReset,
deleteCount, humanizeBytes(totalDeleteSize),
colorGray, colorReset)
} else {
fmt.Fprintf(&b, "%sDelete:%s %s (%s) %sPress ⌫ again | ESC cancel%s\n",
colorRed, colorReset,
m.deleteTarget.Name, humanizeBytes(m.deleteTarget.Size),
colorGray, colorReset)
}
} }
return b.String() return b.String()
} }
// calculateViewport computes the number of visible items based on terminal height. // calculateViewport returns visible rows for the current terminal height.
func calculateViewport(termHeight int, isLargeFiles bool) int { func calculateViewport(termHeight int, isLargeFiles bool) int {
if termHeight <= 0 { if termHeight <= 0 {
// Terminal height unknown, use default
return defaultViewport return defaultViewport
} }
// Calculate reserved space for UI elements reserved := 6 // Header + footer
reserved := 6 // header (3-4 lines) + footer (2 lines)
if isLargeFiles { if isLargeFiles {
reserved = 5 // Large files view has less overhead reserved = 5
} }
available := termHeight - reserved available := termHeight - reserved
// Ensure minimum and maximum bounds
if available < 1 { if available < 1 {
return 1 // Minimum 1 line for very short terminals return 1
} }
if available > 30 { if available > 30 {
return 30 // Maximum 30 lines to avoid information overload return 30
} }
return available return available

View File

@@ -72,7 +72,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.metrics = msg.data m.metrics = msg.data
m.lastUpdated = msg.data.CollectedAt m.lastUpdated = msg.data.CollectedAt
m.collecting = false m.collecting = false
// Mark ready after first successful data collection // Mark ready after first successful data collection.
if !m.ready { if !m.ready {
m.ready = true m.ready = true
} }
@@ -126,7 +126,7 @@ func animTick() tea.Cmd {
} }
func animTickWithSpeed(cpuUsage float64) tea.Cmd { func animTickWithSpeed(cpuUsage float64) tea.Cmd {
// Higher CPU = faster animation (50ms to 300ms) // Higher CPU = faster animation.
interval := 300 - int(cpuUsage*2.5) interval := 300 - int(cpuUsage*2.5)
if interval < 50 { if interval < 50 {
interval = 50 interval = 50

View File

@@ -118,10 +118,13 @@ type BatteryStatus struct {
} }
type ThermalStatus struct { type ThermalStatus struct {
CPUTemp float64 CPUTemp float64
GPUTemp float64 GPUTemp float64
FanSpeed int FanSpeed int
FanCount int FanCount int
SystemPower float64 // System power consumption in Watts
AdapterPower float64 // AC adapter max power in Watts
BatteryPower float64 // Battery charge/discharge power in Watts (positive = discharging)
} }
type SensorReading struct { type SensorReading struct {
@@ -138,10 +141,18 @@ type BluetoothDevice struct {
} }
type Collector struct { type Collector struct {
// Static cache.
cachedHW HardwareInfo
lastHWAt time.Time
hasStatic bool
// Slow cache (30s-1m).
lastBTAt time.Time
lastBT []BluetoothDevice
// Fast metrics (1s).
prevNet map[string]net.IOCountersStat prevNet map[string]net.IOCountersStat
lastNetAt time.Time lastNetAt time.Time
lastBTAt time.Time
lastBT []BluetoothDevice
lastGPUAt time.Time lastGPUAt time.Time
cachedGPU []GPUStatus cachedGPU []GPUStatus
prevDiskIO disk.IOCountersStat prevDiskIO disk.IOCountersStat
@@ -157,9 +168,7 @@ func NewCollector() *Collector {
func (c *Collector) Collect() (MetricsSnapshot, error) { func (c *Collector) Collect() (MetricsSnapshot, error) {
now := time.Now() now := time.Now()
// Start host info collection early (it's fast but good to parallelize if possible, // Host info is cached by gopsutil; fetch once.
// but it returns a struct needed for result, so we can just run it here or in parallel)
// host.Info is usually cached by gopsutil but let's just call it.
hostInfo, _ := host.Info() hostInfo, _ := host.Info()
var ( var (
@@ -181,7 +190,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
topProcs []ProcessInfo topProcs []ProcessInfo
) )
// Helper to launch concurrent collection // Helper to launch concurrent collection.
collect := func(fn func() error) { collect := func(fn func() error) {
wg.Add(1) wg.Add(1)
go func() { go func() {
@@ -198,7 +207,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
}() }()
} }
// Launch all independent collection tasks // Launch independent collection tasks.
collect(func() (err error) { cpuStats, err = collectCPU(); return }) collect(func() (err error) { cpuStats, err = collectCPU(); return })
collect(func() (err error) { memStats, err = collectMemory(); return }) collect(func() (err error) { memStats, err = collectMemory(); return })
collect(func() (err error) { diskStats, err = collectDisks(); return }) collect(func() (err error) { diskStats, err = collectDisks(); return })
@@ -209,14 +218,31 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
collect(func() (err error) { thermalStats = collectThermal(); return nil }) collect(func() (err error) { thermalStats = collectThermal(); return nil })
collect(func() (err error) { sensorStats, _ = collectSensors(); return nil }) collect(func() (err error) { sensorStats, _ = collectSensors(); return nil })
collect(func() (err error) { gpuStats, err = c.collectGPU(now); return }) collect(func() (err error) { gpuStats, err = c.collectGPU(now); return })
collect(func() (err error) { btStats = c.collectBluetooth(now); return nil }) collect(func() (err error) {
// Bluetooth is slow; cache for 30s.
if now.Sub(c.lastBTAt) > 30*time.Second || len(c.lastBT) == 0 {
btStats = c.collectBluetooth(now)
c.lastBT = btStats
c.lastBTAt = now
} else {
btStats = c.lastBT
}
return nil
})
collect(func() (err error) { topProcs = collectTopProcesses(); return nil }) collect(func() (err error) { topProcs = collectTopProcesses(); return nil })
// Wait for all to complete // Wait for all to complete.
wg.Wait() wg.Wait()
// Dependent tasks (must run after others) // Dependent tasks (post-collect).
hwInfo := collectHardware(memStats.Total, diskStats) // Cache hardware info as it's expensive and rarely changes.
if !c.hasStatic || now.Sub(c.lastHWAt) > 10*time.Minute {
c.cachedHW = collectHardware(memStats.Total, diskStats)
c.lastHWAt = now
c.hasStatic = true
}
hwInfo := c.cachedHW
score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats) score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats)
return MetricsSnapshot{ return MetricsSnapshot{
@@ -243,8 +269,6 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
}, mergeErr }, mergeErr
} }
// Utility functions
func runCmd(ctx context.Context, name string, args ...string) (string, error) { func runCmd(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...) cmd := exec.CommandContext(ctx, name, args...)
output, err := cmd.Output() output, err := cmd.Output()
@@ -260,11 +284,9 @@ func commandExists(name string) bool {
} }
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
// If LookPath panics due to permissions or platform quirks, act as if the command is missing. // Treat LookPath panics as "missing".
} }
}() }()
_, err := exec.LookPath(name) _, err := exec.LookPath(name)
return err == nil return err == nil
} }
// humanBytes is defined in view.go to avoid duplication

View File

@@ -14,24 +14,33 @@ import (
"github.com/shirou/gopsutil/v3/host" "github.com/shirou/gopsutil/v3/host"
) )
var (
// Cache for heavy system_profiler output.
lastPowerAt time.Time
cachedPower string
powerCacheTTL = 30 * time.Second
)
func collectBatteries() (batts []BatteryStatus, err error) { func collectBatteries() (batts []BatteryStatus, err error) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
// Swallow panics from platform-specific battery probes to keep the UI alive. // Swallow panics to keep UI alive.
err = fmt.Errorf("battery collection failed: %v", r) err = fmt.Errorf("battery collection failed: %v", r)
} }
}() }()
// macOS: pmset // macOS: pmset for real-time percentage/status.
if runtime.GOOS == "darwin" && commandExists("pmset") { if runtime.GOOS == "darwin" && commandExists("pmset") {
if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil { if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil {
if batts := parsePMSet(out); len(batts) > 0 { // Health/cycles from cached system_profiler.
health, cycles := getCachedPowerData()
if batts := parsePMSet(out, health, cycles); len(batts) > 0 {
return batts, nil return batts, nil
} }
} }
} }
// Linux: /sys/class/power_supply // Linux: /sys/class/power_supply.
matches, _ := filepath.Glob("/sys/class/power_supply/BAT*/capacity") matches, _ := filepath.Glob("/sys/class/power_supply/BAT*/capacity")
for _, capFile := range matches { for _, capFile := range matches {
statusFile := filepath.Join(filepath.Dir(capFile), "status") statusFile := filepath.Join(filepath.Dir(capFile), "status")
@@ -58,15 +67,14 @@ func collectBatteries() (batts []BatteryStatus, err error) {
return nil, errors.New("no battery data found") return nil, errors.New("no battery data found")
} }
func parsePMSet(raw string) []BatteryStatus { func parsePMSet(raw string, health string, cycles int) []BatteryStatus {
lines := strings.Split(raw, "\n") lines := strings.Split(raw, "\n")
var out []BatteryStatus var out []BatteryStatus
var timeLeft string var timeLeft string
for _, line := range lines { for _, line := range lines {
// Check for time remaining // Time remaining.
if strings.Contains(line, "remaining") { if strings.Contains(line, "remaining") {
// Extract time like "1:30 remaining"
parts := strings.Fields(line) parts := strings.Fields(line)
for i, p := range parts { for i, p := range parts {
if p == "remaining" && i > 0 { if p == "remaining" && i > 0 {
@@ -101,9 +109,6 @@ func parsePMSet(raw string) []BatteryStatus {
continue continue
} }
// Get battery health and cycle count
health, cycles := getBatteryHealth()
out = append(out, BatteryStatus{ out = append(out, BatteryStatus{
Percent: percent, Percent: percent,
Status: status, Status: status,
@@ -115,40 +120,51 @@ func parsePMSet(raw string) []BatteryStatus {
return out return out
} }
func getBatteryHealth() (string, int) { // getCachedPowerData returns condition and cycles from cached system_profiler.
if runtime.GOOS != "darwin" { func getCachedPowerData() (health string, cycles int) {
out := getSystemPowerOutput()
if out == "" {
return "", 0 return "", 0
} }
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out, err := runCmd(ctx, "system_profiler", "SPPowerDataType")
if err != nil {
return "", 0
}
var health string
var cycles int
lines := strings.Split(out, "\n") lines := strings.Split(out, "\n")
for _, line := range lines { for _, line := range lines {
lower := strings.ToLower(line) lower := strings.ToLower(line)
if strings.Contains(lower, "cycle count") { if strings.Contains(lower, "cycle count") {
parts := strings.Split(line, ":") if _, after, found := strings.Cut(line, ":"); found {
if len(parts) == 2 { cycles, _ = strconv.Atoi(strings.TrimSpace(after))
cycles, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
} }
} }
if strings.Contains(lower, "condition") { if strings.Contains(lower, "condition") {
parts := strings.Split(line, ":") if _, after, found := strings.Cut(line, ":"); found {
if len(parts) == 2 { health = strings.TrimSpace(after)
health = strings.TrimSpace(parts[1])
} }
} }
} }
return health, cycles return health, cycles
} }
func getSystemPowerOutput() string {
if runtime.GOOS != "darwin" {
return ""
}
now := time.Now()
if cachedPower != "" && now.Sub(lastPowerAt) < powerCacheTTL {
return cachedPower
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
out, err := runCmd(ctx, "system_profiler", "SPPowerDataType")
if err == nil {
cachedPower = out
lastPowerAt = now
}
return cachedPower
}
func collectThermal() ThermalStatus { func collectThermal() ThermalStatus {
if runtime.GOOS != "darwin" { if runtime.GOOS != "darwin" {
return ThermalStatus{} return ThermalStatus{}
@@ -156,47 +172,85 @@ func collectThermal() ThermalStatus {
var thermal ThermalStatus var thermal ThermalStatus
// Get fan info from system_profiler // Fan info from cached system_profiler.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) out := getSystemPowerOutput()
defer cancel() if out != "" {
out, err := runCmd(ctx, "system_profiler", "SPPowerDataType")
if err == nil {
lines := strings.Split(out, "\n") lines := strings.Split(out, "\n")
for _, line := range lines { 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") {
parts := strings.Split(line, ":") if _, after, found := strings.Cut(line, ":"); found {
if len(parts) == 2 { numStr := strings.TrimSpace(after)
// Extract number from string like "1200 RPM" numStr, _, _ = strings.Cut(numStr, " ")
numStr := strings.TrimSpace(parts[1])
numStr = strings.Split(numStr, " ")[0]
thermal.FanSpeed, _ = strconv.Atoi(numStr) thermal.FanSpeed, _ = strconv.Atoi(numStr)
} }
} }
} }
} }
// 1. Try ioreg battery temperature (simple, no sudo needed) // Power metrics from ioreg (fast, real-time).
ctxIoreg, cancelIoreg := context.WithTimeout(context.Background(), 500*time.Millisecond) ctxPower, cancelPower := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancelIoreg() defer cancelPower()
if out, err := runCmd(ctxIoreg, "sh", "-c", "ioreg -rn AppleSmartBattery | awk '/\"Temperature\"/ {print $3}'"); err == nil { if out, err := runCmd(ctxPower, "ioreg", "-rn", "AppleSmartBattery"); err == nil {
valStr := strings.TrimSpace(out) lines := strings.Split(out, "\n")
if tempRaw, err := strconv.Atoi(valStr); err == nil && tempRaw > 0 { for _, line := range lines {
thermal.CPUTemp = float64(tempRaw) / 100.0 line = strings.TrimSpace(line)
return thermal
// Battery temperature ("Temperature" = 3055).
if _, after, found := strings.Cut(line, "\"Temperature\" = "); found {
valStr := strings.TrimSpace(after)
if tempRaw, err := strconv.Atoi(valStr); err == nil && tempRaw > 0 {
thermal.CPUTemp = float64(tempRaw) / 100.0
}
}
// Adapter power (Watts) from current adapter.
if strings.Contains(line, "\"AdapterDetails\" = {") && !strings.Contains(line, "AppleRaw") {
if _, after, found := strings.Cut(line, "\"Watts\"="); found {
valStr := strings.TrimSpace(after)
valStr, _, _ = strings.Cut(valStr, ",")
valStr, _, _ = strings.Cut(valStr, "}")
valStr = strings.TrimSpace(valStr)
if watts, err := strconv.ParseFloat(valStr, 64); err == nil && watts > 0 {
thermal.AdapterPower = watts
}
}
}
// System power consumption (mW -> W).
if _, after, found := strings.Cut(line, "\"SystemPowerIn\"="); found {
valStr := strings.TrimSpace(after)
valStr, _, _ = strings.Cut(valStr, ",")
valStr, _, _ = strings.Cut(valStr, "}")
valStr = strings.TrimSpace(valStr)
if powerMW, err := strconv.ParseFloat(valStr, 64); err == nil && powerMW > 0 {
thermal.SystemPower = powerMW / 1000.0
}
}
// Battery power (mW -> W, positive = discharging).
if _, after, found := strings.Cut(line, "\"BatteryPower\"="); found {
valStr := strings.TrimSpace(after)
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
}
}
} }
} }
// 2. Try thermal level as a proxy (fallback) // Fallback: thermal level proxy.
ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond) if thermal.CPUTemp == 0 {
defer cancel2() ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond)
out2, err := runCmd(ctx2, "sysctl", "-n", "machdep.xcpm.cpu_thermal_level") defer cancel2()
if err == nil { out2, err := runCmd(ctx2, "sysctl", "-n", "machdep.xcpm.cpu_thermal_level")
level, _ := strconv.Atoi(strings.TrimSpace(out2)) if err == nil {
// Estimate temp: level 0-100 roughly maps to 40-100°C level, _ := strconv.Atoi(strings.TrimSpace(out2))
if level >= 0 { if level >= 0 {
thermal.CPUTemp = 45 + float64(level)*0.5 thermal.CPUTemp = 45 + float64(level)*0.5
}
} }
} }

View File

@@ -80,7 +80,7 @@ func parseSPBluetooth(raw string) []BluetoothDevice {
continue continue
} }
if !strings.HasPrefix(line, " ") && strings.HasSuffix(trim, ":") { if !strings.HasPrefix(line, " ") && strings.HasSuffix(trim, ":") {
// Reset at top-level sections // Reset at top-level sections.
currentName = "" currentName = ""
connected = false connected = false
battery = "" battery = ""

View File

@@ -31,7 +31,10 @@ func collectCPU() (CPUStatus, error) {
logical = 1 logical = 1
} }
percents, err := cpu.Percent(cpuSampleInterval, true) // Two-call pattern for more reliable CPU usage.
cpu.Percent(0, true)
time.Sleep(cpuSampleInterval)
percents, err := cpu.Percent(0, true)
var totalPercent float64 var totalPercent float64
perCoreEstimated := false perCoreEstimated := false
if err != nil || len(percents) == 0 { if err != nil || len(percents) == 0 {
@@ -63,7 +66,7 @@ func collectCPU() (CPUStatus, error) {
} }
} }
// Get P-core and E-core counts for Apple Silicon // P/E core counts for Apple Silicon.
pCores, eCores := getCoreTopology() pCores, eCores := getCoreTopology()
return CPUStatus{ return CPUStatus{
@@ -84,17 +87,29 @@ func isZeroLoad(avg load.AvgStat) bool {
return avg.Load1 == 0 && avg.Load5 == 0 && avg.Load15 == 0 return avg.Load1 == 0 && avg.Load5 == 0 && avg.Load15 == 0
} }
// getCoreTopology returns P-core and E-core counts on Apple Silicon. var (
// Returns (0, 0) on non-Apple Silicon or if detection fails. // Cache for core topology.
lastTopologyAt time.Time
cachedP, cachedE int
topologyTTL = 10 * time.Minute
)
// getCoreTopology returns P/E core counts on Apple Silicon.
func getCoreTopology() (pCores, eCores int) { func getCoreTopology() (pCores, eCores int) {
if runtime.GOOS != "darwin" { if runtime.GOOS != "darwin" {
return 0, 0 return 0, 0
} }
now := time.Now()
if cachedP > 0 || cachedE > 0 {
if now.Sub(lastTopologyAt) < topologyTTL {
return cachedP, cachedE
}
}
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel() defer cancel()
// Get performance level info from sysctl
out, err := runCmd(ctx, "sysctl", "-n", out, err := runCmd(ctx, "sysctl", "-n",
"hw.perflevel0.logicalcpu", "hw.perflevel0.logicalcpu",
"hw.perflevel0.name", "hw.perflevel0.name",
@@ -109,15 +124,12 @@ func getCoreTopology() (pCores, eCores int) {
return 0, 0 return 0, 0
} }
// Parse perflevel0
level0Count, _ := strconv.Atoi(strings.TrimSpace(lines[0])) level0Count, _ := strconv.Atoi(strings.TrimSpace(lines[0]))
level0Name := strings.ToLower(strings.TrimSpace(lines[1])) level0Name := strings.ToLower(strings.TrimSpace(lines[1]))
// Parse perflevel1
level1Count, _ := strconv.Atoi(strings.TrimSpace(lines[2])) level1Count, _ := strconv.Atoi(strings.TrimSpace(lines[2]))
level1Name := strings.ToLower(strings.TrimSpace(lines[3])) level1Name := strings.ToLower(strings.TrimSpace(lines[3]))
// Assign based on name (Performance vs Efficiency)
if strings.Contains(level0Name, "performance") { if strings.Contains(level0Name, "performance") {
pCores = level0Count pCores = level0Count
} else if strings.Contains(level0Name, "efficiency") { } else if strings.Contains(level0Name, "efficiency") {
@@ -130,6 +142,8 @@ func getCoreTopology() (pCores, eCores int) {
eCores = level1Count eCores = level1Count
} }
cachedP, cachedE = pCores, eCores
lastTopologyAt = now
return pCores, eCores return pCores, eCores
} }
@@ -231,10 +245,10 @@ func fallbackCPUUtilization(logical int) (float64, []float64, error) {
total = maxTotal total = maxTotal
} }
perCore := make([]float64, logical)
avg := total / float64(logical) avg := total / float64(logical)
perCore := make([]float64, logical)
for i := range perCore { for i := range perCore {
perCore[i] = avg perCore[i] = avg
} }
return total, perCore, nil return avg, perCore, nil
} }

View File

@@ -43,7 +43,7 @@ func collectDisks() ([]DiskStatus, error) {
if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") { if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") {
continue continue
} }
// Skip private volumes // Skip /private mounts.
if strings.HasPrefix(part.Mountpoint, "/private/") { if strings.HasPrefix(part.Mountpoint, "/private/") {
continue continue
} }
@@ -58,12 +58,11 @@ func collectDisks() ([]DiskStatus, error) {
if err != nil || usage.Total == 0 { if err != nil || usage.Total == 0 {
continue continue
} }
// Skip small volumes (< 1GB) // Skip <1GB volumes.
if usage.Total < 1<<30 { if usage.Total < 1<<30 {
continue continue
} }
// For APFS volumes, use a more precise dedup key (bytes level) // Use size-based dedupe key for shared pools.
// to handle shared storage pools properly
volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total) volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total)
if seenVolume[volKey] { if seenVolume[volKey] {
continue continue
@@ -93,26 +92,42 @@ func collectDisks() ([]DiskStatus, error) {
return disks, nil return disks, nil
} }
var (
// External disk cache.
lastDiskCacheAt time.Time
diskTypeCache = make(map[string]bool)
diskCacheTTL = 2 * time.Minute
)
func annotateDiskTypes(disks []DiskStatus) { func annotateDiskTypes(disks []DiskStatus) {
if len(disks) == 0 || runtime.GOOS != "darwin" || !commandExists("diskutil") { if len(disks) == 0 || runtime.GOOS != "darwin" || !commandExists("diskutil") {
return return
} }
cache := make(map[string]bool)
now := time.Now()
// Clear stale cache.
if now.Sub(lastDiskCacheAt) > diskCacheTTL {
diskTypeCache = make(map[string]bool)
lastDiskCacheAt = now
}
for i := range disks { for i := range disks {
base := baseDeviceName(disks[i].Device) base := baseDeviceName(disks[i].Device)
if base == "" { if base == "" {
base = disks[i].Device base = disks[i].Device
} }
if val, ok := cache[base]; ok {
if val, ok := diskTypeCache[base]; ok {
disks[i].External = val disks[i].External = val
continue continue
} }
external, err := isExternalDisk(base) external, err := isExternalDisk(base)
if err != nil { if err != nil {
external = strings.HasPrefix(disks[i].Mount, "/Volumes/") external = strings.HasPrefix(disks[i].Mount, "/Volumes/")
} }
disks[i].External = external disks[i].External = external
cache[base] = external diskTypeCache[base] = external
} }
} }

View File

@@ -17,7 +17,7 @@ const (
powermetricsTimeout = 2 * time.Second powermetricsTimeout = 2 * time.Second
) )
// Pre-compiled regex patterns for GPU usage parsing // Regex for GPU usage parsing.
var ( var (
gpuActiveResidencyRe = regexp.MustCompile(`GPU HW active residency:\s+([\d.]+)%`) gpuActiveResidencyRe = regexp.MustCompile(`GPU HW active residency:\s+([\d.]+)%`)
gpuIdleResidencyRe = regexp.MustCompile(`GPU idle residency:\s+([\d.]+)%`) gpuIdleResidencyRe = regexp.MustCompile(`GPU idle residency:\s+([\d.]+)%`)
@@ -25,7 +25,7 @@ var (
func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) { func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) {
if runtime.GOOS == "darwin" { if runtime.GOOS == "darwin" {
// Get static GPU info (cached for 10 min) // Static GPU info (cached 10 min).
if len(c.cachedGPU) == 0 || c.lastGPUAt.IsZero() || now.Sub(c.lastGPUAt) >= macGPUInfoTTL { if len(c.cachedGPU) == 0 || c.lastGPUAt.IsZero() || now.Sub(c.lastGPUAt) >= macGPUInfoTTL {
if gpus, err := readMacGPUInfo(); err == nil && len(gpus) > 0 { if gpus, err := readMacGPUInfo(); err == nil && len(gpus) > 0 {
c.cachedGPU = gpus c.cachedGPU = gpus
@@ -33,12 +33,12 @@ func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) {
} }
} }
// Get real-time GPU usage // Real-time GPU usage.
if len(c.cachedGPU) > 0 { if len(c.cachedGPU) > 0 {
usage := getMacGPUUsage() usage := getMacGPUUsage()
result := make([]GPUStatus, len(c.cachedGPU)) result := make([]GPUStatus, len(c.cachedGPU))
copy(result, c.cachedGPU) copy(result, c.cachedGPU)
// Apply usage to first GPU (Apple Silicon has one integrated GPU) // Apply usage to first GPU (Apple Silicon).
if len(result) > 0 { if len(result) > 0 {
result[0].Usage = usage result[0].Usage = usage
} }
@@ -152,19 +152,18 @@ func readMacGPUInfo() ([]GPUStatus, error) {
return gpus, nil return gpus, nil
} }
// getMacGPUUsage gets GPU active residency from powermetrics. // getMacGPUUsage reads GPU active residency from powermetrics.
// Returns -1 if unavailable (e.g., not running as root).
func getMacGPUUsage() float64 { func getMacGPUUsage() float64 {
ctx, cancel := context.WithTimeout(context.Background(), powermetricsTimeout) ctx, cancel := context.WithTimeout(context.Background(), powermetricsTimeout)
defer cancel() defer cancel()
// powermetrics requires root, but we try anyway - some systems may have it enabled // powermetrics may require root.
out, err := runCmd(ctx, "powermetrics", "--samplers", "gpu_power", "-i", "500", "-n", "1") out, err := runCmd(ctx, "powermetrics", "--samplers", "gpu_power", "-i", "500", "-n", "1")
if err != nil { if err != nil {
return -1 return -1
} }
// Parse "GPU HW active residency: X.XX%" // Parse "GPU HW active residency: X.XX%".
matches := gpuActiveResidencyRe.FindStringSubmatch(out) matches := gpuActiveResidencyRe.FindStringSubmatch(out)
if len(matches) >= 2 { if len(matches) >= 2 {
usage, err := strconv.ParseFloat(matches[1], 64) usage, err := strconv.ParseFloat(matches[1], 64)
@@ -173,7 +172,7 @@ func getMacGPUUsage() float64 {
} }
} }
// Fallback: parse "GPU idle residency: X.XX%" and calculate active // Fallback: parse idle residency and derive active.
matchesIdle := gpuIdleResidencyRe.FindStringSubmatch(out) matchesIdle := gpuIdleResidencyRe.FindStringSubmatch(out)
if len(matchesIdle) >= 2 { if len(matchesIdle) >= 2 {
idle, err := strconv.ParseFloat(matchesIdle[1], 64) idle, err := strconv.ParseFloat(matchesIdle[1], 64)

View File

@@ -18,19 +18,18 @@ func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo {
} }
} }
// Get model and CPU from system_profiler // Model and CPU from system_profiler.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
var model, cpuModel, osVersion string var model, cpuModel, osVersion string
// Get hardware overview
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") lines := strings.Split(out, "\n")
for _, line := range lines { 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:") {
parts := strings.Split(line, ":") parts := strings.Split(line, ":")
if len(parts) == 2 { if len(parts) == 2 {
@@ -52,7 +51,6 @@ func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo {
} }
} }
// Get macOS version
ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Second) ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel2() defer cancel2()
out2, err := runCmd(ctx2, "sw_vers", "-productVersion") out2, err := runCmd(ctx2, "sw_vers", "-productVersion")
@@ -60,7 +58,6 @@ func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo {
osVersion = "macOS " + strings.TrimSpace(out2) osVersion = "macOS " + strings.TrimSpace(out2)
} }
// Get disk size
diskSize := "Unknown" diskSize := "Unknown"
if len(disks) > 0 { if len(disks) > 0 {
diskSize = humanBytes(disks[0].Total) diskSize = humanBytes(disks[0].Total)

View File

@@ -5,45 +5,43 @@ import (
"strings" "strings"
) )
// Health score calculation weights and thresholds // Health score weights and thresholds.
const ( const (
// Weights (must sum to ~100 for total score) // Weights.
healthCPUWeight = 30.0 healthCPUWeight = 30.0
healthMemWeight = 25.0 healthMemWeight = 25.0
healthDiskWeight = 20.0 healthDiskWeight = 20.0
healthThermalWeight = 15.0 healthThermalWeight = 15.0
healthIOWeight = 10.0 healthIOWeight = 10.0
// CPU thresholds // CPU.
cpuNormalThreshold = 30.0 cpuNormalThreshold = 30.0
cpuHighThreshold = 70.0 cpuHighThreshold = 70.0
// Memory thresholds // Memory.
memNormalThreshold = 50.0 memNormalThreshold = 50.0
memHighThreshold = 80.0 memHighThreshold = 80.0
memPressureWarnPenalty = 5.0 memPressureWarnPenalty = 5.0
memPressureCritPenalty = 15.0 memPressureCritPenalty = 15.0
// Disk thresholds // Disk.
diskWarnThreshold = 70.0 diskWarnThreshold = 70.0
diskCritThreshold = 90.0 diskCritThreshold = 90.0
// Thermal thresholds // Thermal.
thermalNormalThreshold = 60.0 thermalNormalThreshold = 60.0
thermalHighThreshold = 85.0 thermalHighThreshold = 85.0
// Disk IO thresholds (MB/s) // Disk IO (MB/s).
ioNormalThreshold = 50.0 ioNormalThreshold = 50.0
ioHighThreshold = 150.0 ioHighThreshold = 150.0
) )
func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, diskIO DiskIOStatus, thermal ThermalStatus) (int, string) { func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, diskIO DiskIOStatus, thermal ThermalStatus) (int, string) {
// Start with perfect score
score := 100.0 score := 100.0
issues := []string{} issues := []string{}
// CPU Usage (30% weight) - deduct up to 30 points // CPU penalty.
// 0-30% CPU = 0 deduction, 30-70% = linear, 70-100% = heavy penalty
cpuPenalty := 0.0 cpuPenalty := 0.0
if cpu.Usage > cpuNormalThreshold { if cpu.Usage > cpuNormalThreshold {
if cpu.Usage > cpuHighThreshold { if cpu.Usage > cpuHighThreshold {
@@ -57,8 +55,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
issues = append(issues, "High CPU") issues = append(issues, "High CPU")
} }
// Memory Usage (25% weight) - deduct up to 25 points // Memory penalty.
// 0-50% = 0 deduction, 50-80% = linear, 80-100% = heavy penalty
memPenalty := 0.0 memPenalty := 0.0
if mem.UsedPercent > memNormalThreshold { if mem.UsedPercent > memNormalThreshold {
if mem.UsedPercent > memHighThreshold { if mem.UsedPercent > memHighThreshold {
@@ -72,7 +69,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
issues = append(issues, "High Memory") issues = append(issues, "High Memory")
} }
// Memory Pressure (extra penalty) // Memory pressure penalty.
if mem.Pressure == "warn" { if mem.Pressure == "warn" {
score -= memPressureWarnPenalty score -= memPressureWarnPenalty
issues = append(issues, "Memory Pressure") issues = append(issues, "Memory Pressure")
@@ -81,7 +78,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
issues = append(issues, "Critical Memory") issues = append(issues, "Critical Memory")
} }
// Disk Usage (20% weight) - deduct up to 20 points // Disk penalty.
diskPenalty := 0.0 diskPenalty := 0.0
if len(disks) > 0 { if len(disks) > 0 {
diskUsage := disks[0].UsedPercent diskUsage := disks[0].UsedPercent
@@ -98,7 +95,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
} }
} }
// Thermal (15% weight) - deduct up to 15 points // Thermal penalty.
thermalPenalty := 0.0 thermalPenalty := 0.0
if thermal.CPUTemp > 0 { if thermal.CPUTemp > 0 {
if thermal.CPUTemp > thermalNormalThreshold { if thermal.CPUTemp > thermalNormalThreshold {
@@ -112,7 +109,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
score -= thermalPenalty score -= thermalPenalty
} }
// Disk IO (10% weight) - deduct up to 10 points // Disk IO penalty.
ioPenalty := 0.0 ioPenalty := 0.0
totalIO := diskIO.ReadRate + diskIO.WriteRate totalIO := diskIO.ReadRate + diskIO.WriteRate
if totalIO > ioNormalThreshold { if totalIO > ioNormalThreshold {
@@ -125,7 +122,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
} }
score -= ioPenalty score -= ioPenalty
// Ensure score is in valid range // Clamp score.
if score < 0 { if score < 0 {
score = 0 score = 0
} }
@@ -133,7 +130,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
score = 100 score = 100
} }
// Generate message // Build message.
msg := "Excellent" msg := "Excellent"
if score >= 90 { if score >= 90 {
msg = "Excellent" msg = "Excellent"

View File

@@ -17,7 +17,7 @@ func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) {
return nil, err return nil, err
} }
// Get IP addresses for interfaces // Map interface IPs.
ifAddrs := getInterfaceIPs() ifAddrs := getInterfaceIPs()
if c.lastNetAt.IsZero() { if c.lastNetAt.IsZero() {
@@ -81,7 +81,7 @@ func getInterfaceIPs() map[string]string {
} }
for _, iface := range ifaces { for _, iface := range ifaces {
for _, addr := range iface.Addrs { for _, addr := range iface.Addrs {
// Only IPv4 // IPv4 only.
if strings.Contains(addr.Addr, ".") && !strings.HasPrefix(addr.Addr, "127.") { if strings.Contains(addr.Addr, ".") && !strings.HasPrefix(addr.Addr, "127.") {
ip := strings.Split(addr.Addr, "/")[0] ip := strings.Split(addr.Addr, "/")[0]
result[iface.Name] = ip result[iface.Name] = ip
@@ -104,14 +104,14 @@ func isNoiseInterface(name string) bool {
} }
func collectProxy() ProxyStatus { func collectProxy() ProxyStatus {
// Check environment variables first // Check environment variables first.
for _, env := range []string{"https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"} { for _, env := range []string{"https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"} {
if val := os.Getenv(env); val != "" { if val := os.Getenv(env); val != "" {
proxyType := "HTTP" proxyType := "HTTP"
if strings.HasPrefix(val, "socks") { if strings.HasPrefix(val, "socks") {
proxyType = "SOCKS" proxyType = "SOCKS"
} }
// Extract host // Extract host.
host := val host := val
if strings.Contains(host, "://") { if strings.Contains(host, "://") {
host = strings.SplitN(host, "://", 2)[1] host = strings.SplitN(host, "://", 2)[1]
@@ -123,7 +123,7 @@ func collectProxy() ProxyStatus {
} }
} }
// macOS: check system proxy via scutil // macOS: check system proxy via scutil.
if runtime.GOOS == "darwin" { if runtime.GOOS == "darwin" {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel() defer cancel()

View File

@@ -15,7 +15,7 @@ func collectTopProcesses() []ProcessInfo {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel() defer cancel()
// Use ps to get top processes by CPU // Use ps to get top processes by CPU.
out, err := runCmd(ctx, "ps", "-Aceo", "pcpu,pmem,comm", "-r") out, err := runCmd(ctx, "ps", "-Aceo", "pcpu,pmem,comm", "-r")
if err != nil { if err != nil {
return nil return nil
@@ -24,10 +24,10 @@ func collectTopProcesses() []ProcessInfo {
lines := strings.Split(strings.TrimSpace(out), "\n") lines := strings.Split(strings.TrimSpace(out), "\n")
var procs []ProcessInfo var procs []ProcessInfo
for i, line := range lines { for i, line := range lines {
if i == 0 { // skip header if i == 0 {
continue continue
} }
if i > 5 { // top 5 if i > 5 {
break break
} }
fields := strings.Fields(line) fields := strings.Fields(line)
@@ -37,7 +37,7 @@ func collectTopProcesses() []ProcessInfo {
cpuVal, _ := strconv.ParseFloat(fields[0], 64) cpuVal, _ := strconv.ParseFloat(fields[0], 64)
memVal, _ := strconv.ParseFloat(fields[1], 64) memVal, _ := strconv.ParseFloat(fields[1], 64)
name := fields[len(fields)-1] name := fields[len(fields)-1]
// Get just the process name without path // Strip path from command name.
if idx := strings.LastIndex(name, "/"); idx >= 0 { if idx := strings.LastIndex(name, "/"); idx >= 0 {
name = name[idx+1:] name = name[idx+1:]
} }

View File

@@ -11,28 +11,29 @@ import (
) )
var ( var (
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C79FD7")).Bold(true) titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C79FD7")).Bold(true)
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9E9E9E")) subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#737373"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F"))
dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true) dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true)
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")) okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7"))
lineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#5A5A5A")) lineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#404040"))
hatStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")) hatStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF4D4D"))
primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9"))
) )
const ( const (
colWidth = 38 colWidth = 38
iconCPU = "" iconCPU = ""
iconMemory = "" iconMemory = ""
iconGPU = "" iconGPU = ""
iconDisk = "" iconDisk = ""
iconNetwork = "⇅" iconNetwork = "⇅"
iconBattery = "" iconBattery = ""
iconSensors = "" iconSensors = ""
iconProcs = "" iconProcs = ""
) )
// Check if it's Christmas season (Dec 10-31) // isChristmasSeason reports Dec 10-31.
func isChristmasSeason() bool { func isChristmasSeason() bool {
now := time.Now() now := time.Now()
month := now.Month() month := now.Month()
@@ -40,7 +41,7 @@ func isChristmasSeason() bool {
return month == time.December && day >= 10 && day <= 31 return month == time.December && day >= 10 && day <= 31
} }
// Mole body frames (legs animate) // Mole body frames.
var moleBody = [][]string{ var moleBody = [][]string{
{ {
` /\_/\`, ` /\_/\`,
@@ -68,7 +69,7 @@ var moleBody = [][]string{
}, },
} }
// Mole body frames with Christmas hat // Mole body frames with Christmas hat.
var moleBodyWithHat = [][]string{ var moleBodyWithHat = [][]string{
{ {
` *`, ` *`,
@@ -104,7 +105,7 @@ var moleBodyWithHat = [][]string{
}, },
} }
// Generate frames with horizontal movement // getMoleFrame renders the animated mole.
func getMoleFrame(animFrame int, termWidth int) string { func getMoleFrame(animFrame int, termWidth int) string {
var body []string var body []string
var bodyIdx int var bodyIdx int
@@ -118,15 +119,12 @@ func getMoleFrame(animFrame int, termWidth int) string {
body = moleBody[bodyIdx] body = moleBody[bodyIdx]
} }
// Calculate mole width (approximate)
moleWidth := 15 moleWidth := 15
// Move across terminal width
maxPos := termWidth - moleWidth maxPos := termWidth - moleWidth
if maxPos < 0 { if maxPos < 0 {
maxPos = 0 maxPos = 0
} }
// Move position: 0 -> maxPos -> 0
cycleLength := maxPos * 2 cycleLength := maxPos * 2
if cycleLength == 0 { if cycleLength == 0 {
cycleLength = 1 cycleLength = 1
@@ -140,7 +138,6 @@ func getMoleFrame(animFrame int, termWidth int) string {
var lines []string var lines []string
if isChristmas { if isChristmas {
// Render with red hat on first 3 lines
for i, line := range body { for i, line := range body {
if i < 3 { if i < 3 {
lines = append(lines, padding+hatStyle.Render(line)) lines = append(lines, padding+hatStyle.Render(line))
@@ -164,30 +161,33 @@ type cardData struct {
} }
func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int) string { func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int) string {
// Title
title := titleStyle.Render("Mole Status") title := titleStyle.Render("Mole Status")
// Health Score with color and label
scoreStyle := getScoreStyle(m.HealthScore) scoreStyle := getScoreStyle(m.HealthScore)
scoreText := subtleStyle.Render("Health ") + scoreStyle.Render(fmt.Sprintf("● %d", m.HealthScore)) scoreText := subtleStyle.Render("Health ") + scoreStyle.Render(fmt.Sprintf("● %d", m.HealthScore))
// Hardware info // Hardware info for a single line.
infoParts := []string{} infoParts := []string{}
if m.Hardware.Model != "" { if m.Hardware.Model != "" {
infoParts = append(infoParts, m.Hardware.Model) infoParts = append(infoParts, primaryStyle.Render(m.Hardware.Model))
} }
if m.Hardware.CPUModel != "" { if m.Hardware.CPUModel != "" {
cpuInfo := m.Hardware.CPUModel cpuInfo := m.Hardware.CPUModel
// Append GPU core count when available.
if len(m.GPU) > 0 && m.GPU[0].CoreCount > 0 { if len(m.GPU) > 0 && m.GPU[0].CoreCount > 0 {
cpuInfo += fmt.Sprintf(" (%d GPU cores)", m.GPU[0].CoreCount) cpuInfo += fmt.Sprintf(" (%dGPU)", m.GPU[0].CoreCount)
} }
infoParts = append(infoParts, cpuInfo) infoParts = append(infoParts, cpuInfo)
} }
var specs []string
if m.Hardware.TotalRAM != "" { if m.Hardware.TotalRAM != "" {
infoParts = append(infoParts, m.Hardware.TotalRAM) specs = append(specs, m.Hardware.TotalRAM)
} }
if m.Hardware.DiskSize != "" { if m.Hardware.DiskSize != "" {
infoParts = append(infoParts, m.Hardware.DiskSize) specs = append(specs, m.Hardware.DiskSize)
}
if len(specs) > 0 {
infoParts = append(infoParts, strings.Join(specs, "/"))
} }
if m.Hardware.OSVersion != "" { if m.Hardware.OSVersion != "" {
infoParts = append(infoParts, m.Hardware.OSVersion) infoParts = append(infoParts, m.Hardware.OSVersion)
@@ -195,30 +195,24 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int
headerLine := title + " " + scoreText + " " + subtleStyle.Render(strings.Join(infoParts, " · ")) headerLine := title + " " + scoreText + " " + subtleStyle.Render(strings.Join(infoParts, " · "))
// Running mole animation
mole := getMoleFrame(animFrame, termWidth) mole := getMoleFrame(animFrame, termWidth)
if errMsg != "" { if errMsg != "" {
return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", mole, dangerStyle.Render(errMsg), "") return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", mole, dangerStyle.Render("ERROR: "+errMsg), "")
} }
return headerLine + "\n" + mole return headerLine + "\n" + mole
} }
func getScoreStyle(score int) lipgloss.Style { func getScoreStyle(score int) lipgloss.Style {
if score >= 90 { if score >= 90 {
// Excellent - Bright Green
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87FF87")).Bold(true) return lipgloss.NewStyle().Foreground(lipgloss.Color("#87FF87")).Bold(true)
} else if score >= 75 { } else if score >= 75 {
// Good - Green
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")).Bold(true) return lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")).Bold(true)
} else if score >= 60 { } else if score >= 60 {
// Fair - Yellow
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true) return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true)
} else if score >= 40 { } else if score >= 40 {
// Poor - Orange
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true) return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true)
} else { } else {
// Critical - Red
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true) return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true)
} }
} }
@@ -232,7 +226,6 @@ func buildCards(m MetricsSnapshot, _ int) []cardData {
renderProcessCard(m.TopProcesses), renderProcessCard(m.TopProcesses),
renderNetworkCard(m.Network, m.Proxy), renderNetworkCard(m.Network, m.Proxy),
} }
// Only show sensors if we have valid temperature readings
if hasSensorData(m.Sensors) { if hasSensorData(m.Sensors) {
cards = append(cards, renderSensorsCard(m.Sensors)) cards = append(cards, renderSensorsCard(m.Sensors))
} }
@@ -326,7 +319,7 @@ func renderMemoryCard(mem MemoryStatus) cardData {
} else { } else {
lines = append(lines, fmt.Sprintf("Swap %s", subtleStyle.Render("not in use"))) lines = append(lines, fmt.Sprintf("Swap %s", subtleStyle.Render("not in use")))
} }
// Memory pressure // Memory pressure status.
if mem.Pressure != "" { if mem.Pressure != "" {
pressureStyle := okStyle pressureStyle := okStyle
pressureText := "Status " + mem.Pressure pressureText := "Status " + mem.Pressure
@@ -397,7 +390,6 @@ func formatDiskLine(label string, d DiskStatus) string {
} }
func ioBar(rate float64) string { func ioBar(rate float64) string {
// Scale: 0-50 MB/s maps to 0-5 blocks
filled := int(rate / 10.0) filled := int(rate / 10.0)
if filled > 5 { if filled > 5 {
filled = 5 filled = 5
@@ -433,7 +425,7 @@ func renderProcessCard(procs []ProcessInfo) cardData {
} }
func miniBar(percent float64) string { func miniBar(percent float64) string {
filled := int(percent / 20) // 5 chars max for 100% filled := int(percent / 20)
if filled > 5 { if filled > 5 {
filled = 5 filled = 5
} }
@@ -463,7 +455,7 @@ func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData {
txBar := netBar(totalTx) txBar := netBar(totalTx)
lines = append(lines, fmt.Sprintf("Down %s %s", rxBar, formatRate(totalRx))) lines = append(lines, fmt.Sprintf("Down %s %s", rxBar, formatRate(totalRx)))
lines = append(lines, fmt.Sprintf("Up %s %s", txBar, formatRate(totalTx))) lines = append(lines, fmt.Sprintf("Up %s %s", txBar, formatRate(totalTx)))
// Show proxy and IP in one line // Show proxy and IP on one line.
var infoParts []string var infoParts []string
if proxy.Enabled { if proxy.Enabled {
infoParts = append(infoParts, "Proxy "+proxy.Type) infoParts = append(infoParts, "Proxy "+proxy.Type)
@@ -479,7 +471,6 @@ func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData {
} }
func netBar(rate float64) string { func netBar(rate float64) string {
// Scale: 0-10 MB/s maps to 0-5 blocks
filled := int(rate / 2.0) filled := int(rate / 2.0)
if filled > 5 { if filled > 5 {
filled = 5 filled = 5
@@ -503,8 +494,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
lines = append(lines, subtleStyle.Render("No battery")) lines = append(lines, subtleStyle.Render("No battery"))
} else { } else {
b := batts[0] b := batts[0]
// Line 1: label + bar + percentage (consistent with other cards)
// Only show red when battery is critically low
statusLower := strings.ToLower(b.Status) statusLower := strings.ToLower(b.Status)
percentText := fmt.Sprintf("%5.1f%%", b.Percent) percentText := fmt.Sprintf("%5.1f%%", b.Percent)
if b.Percent < 20 && statusLower != "charging" && statusLower != "charged" { if b.Percent < 20 && statusLower != "charging" && statusLower != "charged" {
@@ -512,7 +501,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
} }
lines = append(lines, fmt.Sprintf("Level %s %s", batteryProgressBar(b.Percent), percentText)) lines = append(lines, fmt.Sprintf("Level %s %s", batteryProgressBar(b.Percent), percentText))
// Line 2: status
statusIcon := "" statusIcon := ""
statusStyle := subtleStyle statusStyle := subtleStyle
if statusLower == "charging" || statusLower == "charged" { if statusLower == "charging" || statusLower == "charged" {
@@ -521,7 +509,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
} else if b.Percent < 20 { } else if b.Percent < 20 {
statusStyle = dangerStyle statusStyle = dangerStyle
} }
// Capitalize first letter
statusText := b.Status statusText := b.Status
if len(statusText) > 0 { if len(statusText) > 0 {
statusText = strings.ToUpper(statusText[:1]) + strings.ToLower(statusText[1:]) statusText = strings.ToUpper(statusText[:1]) + strings.ToLower(statusText[1:])
@@ -529,9 +516,18 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
if b.TimeLeft != "" { if b.TimeLeft != "" {
statusText += " · " + b.TimeLeft statusText += " · " + b.TimeLeft
} }
// Add power info.
if statusLower == "charging" || statusLower == "charged" {
if thermal.SystemPower > 0 {
statusText += fmt.Sprintf(" · %.0fW", thermal.SystemPower)
} else if thermal.AdapterPower > 0 {
statusText += fmt.Sprintf(" · %.0fW Adapter", thermal.AdapterPower)
}
} else if thermal.BatteryPower > 0 {
statusText += fmt.Sprintf(" · %.0fW", thermal.BatteryPower)
}
lines = append(lines, statusStyle.Render(statusText+statusIcon)) lines = append(lines, statusStyle.Render(statusText+statusIcon))
// Line 3: Health + cycles + temp
healthParts := []string{} healthParts := []string{}
if b.Health != "" { if b.Health != "" {
healthParts = append(healthParts, b.Health) healthParts = append(healthParts, b.Health)
@@ -540,7 +536,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
healthParts = append(healthParts, fmt.Sprintf("%d cycles", b.CycleCount)) healthParts = append(healthParts, fmt.Sprintf("%d cycles", b.CycleCount))
} }
// Add temperature if available
if thermal.CPUTemp > 0 { if thermal.CPUTemp > 0 {
tempStyle := subtleStyle tempStyle := subtleStyle
if thermal.CPUTemp > 80 { if thermal.CPUTemp > 80 {
@@ -551,7 +546,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
healthParts = append(healthParts, tempStyle.Render(fmt.Sprintf("%.0f°C", thermal.CPUTemp))) healthParts = append(healthParts, tempStyle.Render(fmt.Sprintf("%.0f°C", thermal.CPUTemp)))
} }
// Add fan speed if available
if thermal.FanSpeed > 0 { if thermal.FanSpeed > 0 {
healthParts = append(healthParts, fmt.Sprintf("%d RPM", thermal.FanSpeed)) healthParts = append(healthParts, fmt.Sprintf("%d RPM", thermal.FanSpeed))
} }
@@ -580,14 +574,13 @@ 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) - 1 lineLen := width - lipgloss.Width(titleText) - 2
if lineLen < 4 { if lineLen < 4 {
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")
// Pad to target height
lines := strings.Split(content, "\n") lines := strings.Split(content, "\n")
for len(lines) < height { for len(lines) < height {
lines = append(lines, "") lines = append(lines, "")
@@ -596,7 +589,7 @@ func renderCard(data cardData, width int, height int) string {
} }
func progressBar(percent float64) string { func progressBar(percent float64) string {
total := 18 total := 16
if percent < 0 { if percent < 0 {
percent = 0 percent = 0
} }
@@ -604,9 +597,6 @@ func progressBar(percent float64) string {
percent = 100 percent = 100
} }
filled := int(percent / 100 * float64(total)) filled := int(percent / 100 * float64(total))
if filled > total {
filled = total
}
var builder strings.Builder var builder strings.Builder
for i := 0; i < total; i++ { for i := 0; i < total; i++ {
@@ -620,7 +610,7 @@ func progressBar(percent float64) string {
} }
func batteryProgressBar(percent float64) string { func batteryProgressBar(percent float64) string {
total := 18 total := 16
if percent < 0 { if percent < 0 {
percent = 0 percent = 0
} }
@@ -628,9 +618,6 @@ func batteryProgressBar(percent float64) string {
percent = 100 percent = 100
} }
filled := int(percent / 100 * float64(total)) filled := int(percent / 100 * float64(total))
if filled > total {
filled = total
}
var builder strings.Builder var builder strings.Builder
for i := 0; i < total; i++ { for i := 0; i < total; i++ {
@@ -645,9 +632,9 @@ func batteryProgressBar(percent float64) string {
func colorizePercent(percent float64, s string) string { func colorizePercent(percent float64, s string) string {
switch { switch {
case percent >= 90: case percent >= 85:
return dangerStyle.Render(s) return dangerStyle.Render(s)
case percent >= 70: case percent >= 60:
return warnStyle.Render(s) return warnStyle.Render(s)
default: default:
return okStyle.Render(s) return okStyle.Render(s)
@@ -766,7 +753,6 @@ func renderTwoColumns(cards []cardData, width int) string {
} }
} }
// Add empty lines between rows for separation
var spacedRows []string var spacedRows []string
for i, r := range rows { for i, r := range rows {
if i > 0 { if i > 0 {

2
go.mod
View File

@@ -9,7 +9,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/shirou/gopsutil/v3 v3.24.5 github.com/shirou/gopsutil/v3 v3.24.5
golang.org/x/sync v0.18.0 golang.org/x/sync v0.19.0
) )
require ( require (

4
go.sum
View File

@@ -64,8 +64,8 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -1,16 +1,16 @@
#!/bin/bash #!/bin/bash
# Mole Installation Script # Mole - Installer for manual installs.
# Fetches source/binaries and installs to prefix.
# Supports update and edge installs.
set -euo pipefail set -euo pipefail
# Colors
GREEN='\033[0;32m' GREEN='\033[0;32m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
RED='\033[0;31m' RED='\033[0;31m'
NC='\033[0m' NC='\033[0m'
# Simple spinner
_SPINNER_PID="" _SPINNER_PID=""
start_line_spinner() { start_line_spinner() {
local msg="$1" local msg="$1"
@@ -36,67 +36,54 @@ stop_line_spinner() { if [[ -n "$_SPINNER_PID" ]]; then
printf "\r\033[K" printf "\r\033[K"
fi; } fi; }
# Verbosity (0 = quiet, 1 = verbose)
VERBOSE=1 VERBOSE=1
# Icons (duplicated from lib/core/common.sh - necessary as install.sh runs standalone) # Icons duplicated from lib/core/common.sh (install.sh runs standalone).
readonly ICON_SUCCESS="✓" # Avoid readonly to prevent conflicts when sourcing common.sh later.
readonly ICON_ADMIN="" ICON_SUCCESS=""
readonly ICON_CONFIRM="" ICON_ADMIN=""
readonly ICON_ERROR="" ICON_CONFIRM=""
ICON_ERROR="☻"
# Logging functions
log_info() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}$1${NC}"; } log_info() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}$1${NC}"; }
log_success() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${GREEN}${ICON_SUCCESS}${NC} $1"; } log_success() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${GREEN}${ICON_SUCCESS}${NC} $1"; }
log_warning() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${YELLOW}$1${NC}"; } log_warning() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${YELLOW}$1${NC}"; }
log_error() { echo -e "${RED}${ICON_ERROR}${NC} $1"; } log_error() { echo -e "${YELLOW}${ICON_ERROR}${NC} $1"; }
log_admin() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}${ICON_ADMIN}${NC} $1"; } log_admin() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}${ICON_ADMIN}${NC} $1"; }
log_confirm() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}${ICON_CONFIRM}${NC} $1"; } log_confirm() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}${ICON_CONFIRM}${NC} $1"; }
# Default installation directory # Install defaults
INSTALL_DIR="/usr/local/bin" INSTALL_DIR="/usr/local/bin"
CONFIG_DIR="$HOME/.config/mole" CONFIG_DIR="$HOME/.config/mole"
SOURCE_DIR="" SOURCE_DIR=""
# Default action (install|update)
ACTION="install" ACTION="install"
show_help() { # Resolve source dir (local checkout, env override, or download).
cat << 'EOF' needs_sudo() {
Mole Installation Script if [[ -e "$INSTALL_DIR" ]]; then
======================== [[ ! -w "$INSTALL_DIR" ]]
return
fi
USAGE: local parent_dir
./install.sh [OPTIONS] parent_dir="$(dirname "$INSTALL_DIR")"
[[ ! -w "$parent_dir" ]]
OPTIONS: }
--prefix PATH Install to custom directory (default: /usr/local/bin)
--config PATH Config directory (default: ~/.config/mole) maybe_sudo() {
--update Update Mole to the latest version if needs_sudo; then
--uninstall Uninstall mole sudo "$@"
--help, -h Show this help else
"$@"
EXAMPLES: fi
./install.sh # Install to /usr/local/bin
./install.sh --prefix ~/.local/bin # Install to custom directory
./install.sh --update # Update Mole in place
./install.sh --uninstall # Uninstall mole
The installer will:
1. Copy mole binary and scripts to the install directory
2. Set up config directory with all modules
3. Make the mole command available system-wide
EOF
echo ""
} }
# Resolve the directory containing source files (supports curl | bash)
resolve_source_dir() { resolve_source_dir() {
if [[ -n "$SOURCE_DIR" && -d "$SOURCE_DIR" && -f "$SOURCE_DIR/mole" ]]; then if [[ -n "$SOURCE_DIR" && -d "$SOURCE_DIR" && -f "$SOURCE_DIR/mole" ]]; then
return 0 return 0
fi fi
# 1) If script is on disk, use its directory (only when mole executable present)
if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then
local script_dir local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -106,27 +93,53 @@ resolve_source_dir() {
fi fi
fi fi
# 2) If CLEAN_SOURCE_DIR env is provided, honor it
if [[ -n "${CLEAN_SOURCE_DIR:-}" && -d "$CLEAN_SOURCE_DIR" && -f "$CLEAN_SOURCE_DIR/mole" ]]; then if [[ -n "${CLEAN_SOURCE_DIR:-}" && -d "$CLEAN_SOURCE_DIR" && -f "$CLEAN_SOURCE_DIR/mole" ]]; then
SOURCE_DIR="$CLEAN_SOURCE_DIR" SOURCE_DIR="$CLEAN_SOURCE_DIR"
return 0 return 0
fi fi
# 3) Fallback: fetch repository to a temp directory (works for curl | bash)
local tmp local tmp
tmp="$(mktemp -d)" tmp="$(mktemp -d)"
# Expand tmp now so trap doesn't depend on local scope trap "stop_line_spinner 2>/dev/null; rm -rf '$tmp'" EXIT
trap "rm -rf '$tmp'" EXIT
start_line_spinner "Fetching Mole source..." local branch="${MOLE_VERSION:-}"
if [[ -z "$branch" ]]; then
branch="$(get_latest_release_tag || true)"
fi
if [[ -z "$branch" ]]; then
branch="$(get_latest_release_tag_from_git || true)"
fi
if [[ -z "$branch" ]]; then
branch="main"
fi
if [[ "$branch" != "main" ]]; then
branch="$(normalize_release_tag "$branch")"
fi
local url="https://github.com/tw93/mole/archive/refs/heads/main.tar.gz"
if [[ "$branch" != "main" ]]; then
url="https://github.com/tw93/mole/archive/refs/tags/${branch}.tar.gz"
fi
start_line_spinner "Fetching Mole source (${branch})..."
if command -v curl > /dev/null 2>&1; then if command -v curl > /dev/null 2>&1; then
if curl -fsSL -o "$tmp/mole.tar.gz" "https://github.com/tw93/mole/archive/refs/heads/main.tar.gz"; then if curl -fsSL -o "$tmp/mole.tar.gz" "$url" 2> /dev/null; then
if tar -xzf "$tmp/mole.tar.gz" -C "$tmp" 2> /dev/null; then
stop_line_spinner
local extracted_dir
extracted_dir=$(find "$tmp" -mindepth 1 -maxdepth 1 -type d | head -n 1)
if [[ -n "$extracted_dir" && -f "$extracted_dir/mole" ]]; then
SOURCE_DIR="$extracted_dir"
return 0
fi
fi
else
stop_line_spinner stop_line_spinner
tar -xzf "$tmp/mole.tar.gz" -C "$tmp" if [[ "$branch" != "main" ]]; then
# Extracted folder name: mole-main log_error "Failed to fetch version ${branch}. Check if tag exists."
if [[ -d "$tmp/mole-main" ]]; then exit 1
SOURCE_DIR="$tmp/mole-main"
return 0
fi fi
fi fi
fi fi
@@ -134,7 +147,12 @@ resolve_source_dir() {
start_line_spinner "Cloning Mole source..." start_line_spinner "Cloning Mole source..."
if command -v git > /dev/null 2>&1; then if command -v git > /dev/null 2>&1; then
if git clone --depth=1 https://github.com/tw93/mole.git "$tmp/mole" > /dev/null 2>&1; then local git_args=("--depth=1")
if [[ "$branch" != "main" ]]; then
git_args+=("--branch" "$branch")
fi
if git clone "${git_args[@]}" https://github.com/tw93/mole.git "$tmp/mole" > /dev/null 2>&1; then
stop_line_spinner stop_line_spinner
SOURCE_DIR="$tmp/mole" SOURCE_DIR="$tmp/mole"
return 0 return 0
@@ -146,6 +164,7 @@ resolve_source_dir() {
exit 1 exit 1
} }
# Version helpers
get_source_version() { get_source_version() {
local source_mole="$SOURCE_DIR/mole" local source_mole="$SOURCE_DIR/mole"
if [[ -f "$source_mole" ]]; then if [[ -f "$source_mole" ]]; then
@@ -153,30 +172,118 @@ get_source_version() {
fi fi
} }
get_latest_release_tag() {
local tag
if ! command -v curl > /dev/null 2>&1; then
return 1
fi
tag=$(curl -fsSL --connect-timeout 2 --max-time 3 \
"https://api.github.com/repos/tw93/mole/releases/latest" 2> /dev/null |
sed -n 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
if [[ -z "$tag" ]]; then
return 1
fi
printf '%s\n' "$tag"
}
get_latest_release_tag_from_git() {
if ! command -v git > /dev/null 2>&1; then
return 1
fi
git ls-remote --tags --refs https://github.com/tw93/mole.git 2> /dev/null |
awk -F/ '{print $NF}' |
grep -E '^V[0-9]' |
sort -V |
tail -n 1
}
normalize_release_tag() {
local tag="$1"
while [[ "$tag" =~ ^[vV] ]]; do
tag="${tag#v}"
tag="${tag#V}"
done
if [[ -n "$tag" ]]; then
printf 'V%s\n' "$tag"
fi
}
get_installed_version() { get_installed_version() {
local binary="$INSTALL_DIR/mole" local binary="$INSTALL_DIR/mole"
if [[ -x "$binary" ]]; then if [[ -x "$binary" ]]; then
# Try running the binary first (preferred method)
local version local version
version=$("$binary" --version 2> /dev/null | awk 'NF {print $NF; exit}') version=$("$binary" --version 2> /dev/null | awk '/Mole version/ {print $NF; exit}')
if [[ -n "$version" ]]; then if [[ -n "$version" ]]; then
echo "$version" echo "$version"
else else
# Fallback: parse VERSION from file (in case binary is broken)
sed -n 's/^VERSION="\(.*\)"$/\1/p' "$binary" | head -n1 sed -n 's/^VERSION="\(.*\)"$/\1/p' "$binary" | head -n1
fi fi
fi fi
} }
# Parse command line arguments # CLI parsing (supports main/latest and version tokens).
parse_args() { parse_args() {
local -a args=("$@")
local version_token=""
local i skip_next=false
for i in "${!args[@]}"; do
local token="${args[$i]}"
[[ -z "$token" ]] && continue
# Skip values for options that take arguments
if [[ "$skip_next" == "true" ]]; then
skip_next=false
continue
fi
if [[ "$token" == "--prefix" || "$token" == "--config" ]]; then
skip_next=true
continue
fi
if [[ "$token" == -* ]]; then
continue
fi
if [[ -n "$version_token" ]]; then
log_error "Unexpected argument: $token"
exit 1
fi
case "$token" in
latest | main)
export MOLE_VERSION="main"
export MOLE_EDGE_INSTALL="true"
version_token="$token"
unset 'args[$i]'
;;
[0-9]* | V[0-9]* | v[0-9]*)
export MOLE_VERSION="$token"
version_token="$token"
unset 'args[$i]'
;;
*)
log_error "Unknown option: $token"
exit 1
;;
esac
done
if [[ ${#args[@]} -gt 0 ]]; then
set -- ${args[@]+"${args[@]}"}
else
set --
fi
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
--prefix) --prefix)
if [[ -z "${2:-}" ]]; then
log_error "Missing value for --prefix"
exit 1
fi
INSTALL_DIR="$2" INSTALL_DIR="$2"
shift 2 shift 2
;; ;;
--config) --config)
if [[ -z "${2:-}" ]]; then
log_error "Missing value for --config"
exit 1
fi
CONFIG_DIR="$2" CONFIG_DIR="$2"
shift 2 shift 2
;; ;;
@@ -184,76 +291,177 @@ parse_args() {
ACTION="update" ACTION="update"
shift 1 shift 1
;; ;;
--uninstall)
uninstall_mole
exit 0
;;
--verbose | -v) --verbose | -v)
VERBOSE=1 VERBOSE=1
shift 1 shift 1
;; ;;
--help | -h) --help | -h)
show_help log_error "Unknown option: $1"
exit 0 exit 1
;; ;;
*) *)
log_error "Unknown option: $1" log_error "Unknown option: $1"
show_help
exit 1 exit 1
;; ;;
esac esac
done done
} }
# Check system requirements # Environment checks and directory setup
check_requirements() { check_requirements() {
# Check if running on macOS
if [[ "$OSTYPE" != "darwin"* ]]; then if [[ "$OSTYPE" != "darwin"* ]]; then
log_error "This tool is designed for macOS only" log_error "This tool is designed for macOS only"
exit 1 exit 1
fi fi
# Check if already installed via Homebrew
if command -v brew > /dev/null 2>&1 && brew list mole > /dev/null 2>&1; then if command -v brew > /dev/null 2>&1 && brew list mole > /dev/null 2>&1; then
if [[ "$ACTION" == "update" ]]; then local mole_path
return 0 mole_path=$(command -v mole 2> /dev/null || true)
local is_homebrew_binary=false
if [[ -n "$mole_path" && -L "$mole_path" ]]; then
if readlink "$mole_path" | grep -q "Cellar/mole"; then
is_homebrew_binary=true
fi
fi fi
echo -e "${YELLOW}Mole is installed via Homebrew${NC}" if [[ "$is_homebrew_binary" == "true" ]]; then
echo "" if [[ "$ACTION" == "update" ]]; then
echo "Choose one:" return 0
echo -e " 1. Update via Homebrew: ${GREEN}brew upgrade mole${NC}" fi
echo -e " 2. Switch to manual: ${GREEN}brew uninstall mole${NC} then re-run this"
echo "" echo -e "${YELLOW}Mole is installed via Homebrew${NC}"
exit 1 echo ""
echo "Choose one:"
echo -e " 1. Update via Homebrew: ${GREEN}brew upgrade mole${NC}"
echo -e " 2. Switch to manual: ${GREEN}brew uninstall --force mole${NC} then re-run this"
echo ""
exit 1
else
log_warning "Cleaning up stale Homebrew installation..."
brew uninstall --force mole > /dev/null 2>&1 || true
fi
fi fi
# Check if install directory exists and is writable
if [[ ! -d "$(dirname "$INSTALL_DIR")" ]]; then if [[ ! -d "$(dirname "$INSTALL_DIR")" ]]; then
log_error "Parent directory $(dirname "$INSTALL_DIR") does not exist" log_error "Parent directory $(dirname "$INSTALL_DIR") does not exist"
exit 1 exit 1
fi fi
} }
# Create installation directories
create_directories() { create_directories() {
# Create install directory if it doesn't exist
if [[ ! -d "$INSTALL_DIR" ]]; then if [[ ! -d "$INSTALL_DIR" ]]; then
if [[ "$INSTALL_DIR" == "/usr/local/bin" ]] && [[ ! -w "$(dirname "$INSTALL_DIR")" ]]; then maybe_sudo mkdir -p "$INSTALL_DIR"
sudo mkdir -p "$INSTALL_DIR"
else
mkdir -p "$INSTALL_DIR"
fi
fi fi
# Create config directory if ! mkdir -p "$CONFIG_DIR" "$CONFIG_DIR/bin" "$CONFIG_DIR/lib"; then
mkdir -p "$CONFIG_DIR" log_error "Failed to create config directory: $CONFIG_DIR"
mkdir -p "$CONFIG_DIR/bin" exit 1
mkdir -p "$CONFIG_DIR/lib" fi
} }
# Install files # Binary install helpers
build_binary_from_source() {
local binary_name="$1"
local target_path="$2"
local cmd_dir=""
case "$binary_name" in
analyze)
cmd_dir="cmd/analyze"
;;
status)
cmd_dir="cmd/status"
;;
*)
return 1
;;
esac
if ! command -v go > /dev/null 2>&1; then
return 1
fi
if [[ ! -d "$SOURCE_DIR/$cmd_dir" ]]; then
return 1
fi
if [[ -t 1 ]]; then
start_line_spinner "Building ${binary_name} from source..."
else
echo "Building ${binary_name} from source..."
fi
if (cd "$SOURCE_DIR" && go build -ldflags="-s -w" -o "$target_path" "./$cmd_dir" > /dev/null 2>&1); then
if [[ -t 1 ]]; then stop_line_spinner; fi
chmod +x "$target_path"
log_success "Built ${binary_name} from source"
return 0
fi
if [[ -t 1 ]]; then stop_line_spinner; fi
log_warning "Failed to build ${binary_name} from source"
return 1
}
download_binary() {
local binary_name="$1"
local target_path="$CONFIG_DIR/bin/${binary_name}-go"
local arch
arch=$(uname -m)
local arch_suffix="amd64"
if [[ "$arch" == "arm64" ]]; then
arch_suffix="arm64"
fi
if [[ -f "$SOURCE_DIR/bin/${binary_name}-go" ]]; then
cp "$SOURCE_DIR/bin/${binary_name}-go" "$target_path"
chmod +x "$target_path"
log_success "Installed local ${binary_name} binary"
return 0
elif [[ -f "$SOURCE_DIR/bin/${binary_name}-darwin-${arch_suffix}" ]]; then
cp "$SOURCE_DIR/bin/${binary_name}-darwin-${arch_suffix}" "$target_path"
chmod +x "$target_path"
log_success "Installed local ${binary_name} binary"
return 0
fi
local version
version=$(get_source_version)
if [[ -z "$version" ]]; then
log_warning "Could not determine version for ${binary_name}, trying local build"
if build_binary_from_source "$binary_name" "$target_path"; then
return 0
fi
return 1
fi
local url="https://github.com/tw93/mole/releases/download/V${version}/${binary_name}-darwin-${arch_suffix}"
# Skip preflight network checks to avoid false negatives.
if [[ -t 1 ]]; then
start_line_spinner "Downloading ${binary_name}..."
else
echo "Downloading ${binary_name}..."
fi
if curl -fsSL --connect-timeout 10 --max-time 60 -o "$target_path" "$url"; then
if [[ -t 1 ]]; then stop_line_spinner; fi
chmod +x "$target_path"
log_success "Downloaded ${binary_name} binary"
else
if [[ -t 1 ]]; then stop_line_spinner; fi
log_warning "Could not download ${binary_name} binary (v${version}), trying local build"
if build_binary_from_source "$binary_name" "$target_path"; then
return 0
fi
log_error "Failed to install ${binary_name} binary"
return 1
fi
}
# File installation (bin/lib/scripts + go helpers).
install_files() { install_files() {
resolve_source_dir resolve_source_dir
@@ -265,17 +473,13 @@ install_files() {
install_dir_abs="$(cd "$INSTALL_DIR" && pwd)" install_dir_abs="$(cd "$INSTALL_DIR" && pwd)"
config_dir_abs="$(cd "$CONFIG_DIR" && pwd)" config_dir_abs="$(cd "$CONFIG_DIR" && pwd)"
# Copy main executable when destination differs
if [[ -f "$SOURCE_DIR/mole" ]]; then if [[ -f "$SOURCE_DIR/mole" ]]; then
if [[ "$source_dir_abs" != "$install_dir_abs" ]]; then if [[ "$source_dir_abs" != "$install_dir_abs" ]]; then
if [[ "$INSTALL_DIR" == "/usr/local/bin" ]] && [[ ! -w "$INSTALL_DIR" ]]; then if needs_sudo; then
log_admin "Admin access required for /usr/local/bin" log_admin "Admin access required for /usr/local/bin"
sudo cp "$SOURCE_DIR/mole" "$INSTALL_DIR/mole"
sudo chmod +x "$INSTALL_DIR/mole"
else
cp "$SOURCE_DIR/mole" "$INSTALL_DIR/mole"
chmod +x "$INSTALL_DIR/mole"
fi fi
maybe_sudo cp "$SOURCE_DIR/mole" "$INSTALL_DIR/mole"
maybe_sudo chmod +x "$INSTALL_DIR/mole"
log_success "Installed mole to $INSTALL_DIR" log_success "Installed mole to $INSTALL_DIR"
fi fi
else else
@@ -283,32 +487,30 @@ install_files() {
exit 1 exit 1
fi fi
# Install mo alias for Mole if available
if [[ -f "$SOURCE_DIR/mo" ]]; then if [[ -f "$SOURCE_DIR/mo" ]]; then
if [[ "$source_dir_abs" == "$install_dir_abs" ]]; then if [[ "$source_dir_abs" == "$install_dir_abs" ]]; then
log_success "mo alias already present" log_success "mo alias already present"
else else
if [[ "$INSTALL_DIR" == "/usr/local/bin" ]] && [[ ! -w "$INSTALL_DIR" ]]; then maybe_sudo cp "$SOURCE_DIR/mo" "$INSTALL_DIR/mo"
sudo cp "$SOURCE_DIR/mo" "$INSTALL_DIR/mo" maybe_sudo chmod +x "$INSTALL_DIR/mo"
sudo chmod +x "$INSTALL_DIR/mo"
else
cp "$SOURCE_DIR/mo" "$INSTALL_DIR/mo"
chmod +x "$INSTALL_DIR/mo"
fi
log_success "Installed mo alias" log_success "Installed mo alias"
fi fi
fi fi
# Copy configuration and modules
if [[ -d "$SOURCE_DIR/bin" ]]; then if [[ -d "$SOURCE_DIR/bin" ]]; then
local source_bin_abs="$(cd "$SOURCE_DIR/bin" && pwd)" local source_bin_abs="$(cd "$SOURCE_DIR/bin" && pwd)"
local config_bin_abs="$(cd "$CONFIG_DIR/bin" && pwd)" local config_bin_abs="$(cd "$CONFIG_DIR/bin" && pwd)"
if [[ "$source_bin_abs" == "$config_bin_abs" ]]; then if [[ "$source_bin_abs" == "$config_bin_abs" ]]; then
log_success "Modules already synced" log_success "Modules already synced"
else else
cp -r "$SOURCE_DIR/bin"/* "$CONFIG_DIR/bin/" local -a bin_files=("$SOURCE_DIR/bin"/*)
chmod +x "$CONFIG_DIR/bin"/* if [[ ${#bin_files[@]} -gt 0 ]]; then
log_success "Installed modules" cp -r "${bin_files[@]}" "$CONFIG_DIR/bin/"
for file in "$CONFIG_DIR/bin/"*; do
[[ -e "$file" ]] && chmod +x "$file"
done
log_success "Installed modules"
fi
fi fi
fi fi
@@ -318,12 +520,14 @@ install_files() {
if [[ "$source_lib_abs" == "$config_lib_abs" ]]; then if [[ "$source_lib_abs" == "$config_lib_abs" ]]; then
log_success "Libraries already synced" log_success "Libraries already synced"
else else
cp -r "$SOURCE_DIR/lib"/* "$CONFIG_DIR/lib/" local -a lib_files=("$SOURCE_DIR/lib"/*)
log_success "Installed libraries" if [[ ${#lib_files[@]} -gt 0 ]]; then
cp -r "${lib_files[@]}" "$CONFIG_DIR/lib/"
log_success "Installed libraries"
fi
fi fi
fi fi
# Copy other files if they exist and directories differ
if [[ "$config_dir_abs" != "$source_dir_abs" ]]; then if [[ "$config_dir_abs" != "$source_dir_abs" ]]; then
for file in README.md LICENSE install.sh; do for file in README.md LICENSE install.sh; do
if [[ -f "$SOURCE_DIR/$file" ]]; then if [[ -f "$SOURCE_DIR/$file" ]]; then
@@ -336,22 +540,23 @@ install_files() {
chmod +x "$CONFIG_DIR/install.sh" chmod +x "$CONFIG_DIR/install.sh"
fi fi
# Update the mole script to use the config directory when installed elsewhere
if [[ "$source_dir_abs" != "$install_dir_abs" ]]; then if [[ "$source_dir_abs" != "$install_dir_abs" ]]; then
if [[ "$INSTALL_DIR" == "/usr/local/bin" ]] && [[ ! -w "$INSTALL_DIR" ]]; then maybe_sudo sed -i '' "s|SCRIPT_DIR=.*|SCRIPT_DIR=\"$CONFIG_DIR\"|" "$INSTALL_DIR/mole"
sudo sed -i '' "s|SCRIPT_DIR=.*|SCRIPT_DIR=\"$CONFIG_DIR\"|" "$INSTALL_DIR/mole" fi
else
sed -i '' "s|SCRIPT_DIR=.*|SCRIPT_DIR=\"$CONFIG_DIR\"|" "$INSTALL_DIR/mole" if ! download_binary "analyze"; then
fi exit 1
fi
if ! download_binary "status"; then
exit 1
fi fi
} }
# Verify installation # Verification and PATH hint
verify_installation() { verify_installation() {
if [[ -x "$INSTALL_DIR/mole" ]] && [[ -f "$CONFIG_DIR/lib/core/common.sh" ]]; then if [[ -x "$INSTALL_DIR/mole" ]] && [[ -f "$CONFIG_DIR/lib/core/common.sh" ]]; then
# Test if mole command works
if "$INSTALL_DIR/mole" --help > /dev/null 2>&1; then if "$INSTALL_DIR/mole" --help > /dev/null 2>&1; then
return 0 return 0
else else
@@ -363,14 +568,11 @@ verify_installation() {
fi fi
} }
# Add to PATH if needed
setup_path() { setup_path() {
# Check if install directory is in PATH
if [[ ":$PATH:" == *":$INSTALL_DIR:"* ]]; then if [[ ":$PATH:" == *":$INSTALL_DIR:"* ]]; then
return return
fi fi
# Only suggest PATH setup for custom directories
if [[ "$INSTALL_DIR" != "/usr/local/bin" ]]; then if [[ "$INSTALL_DIR" != "/usr/local/bin" ]]; then
log_warning "$INSTALL_DIR is not in your PATH" log_warning "$INSTALL_DIR is not in your PATH"
echo "" echo ""
@@ -428,77 +630,7 @@ print_usage_summary() {
echo "" echo ""
} }
# Uninstall function # Main install/update flows
uninstall_mole() {
log_confirm "Uninstalling Mole"
echo ""
# Remove executable
if [[ -f "$INSTALL_DIR/mole" ]]; then
if [[ "$INSTALL_DIR" == "/usr/local/bin" ]] && [[ ! -w "$INSTALL_DIR" ]]; then
log_admin "Admin access required"
sudo rm -f "$INSTALL_DIR/mole"
else
rm -f "$INSTALL_DIR/mole"
fi
log_success "Removed mole executable"
fi
if [[ -f "$INSTALL_DIR/mo" ]]; then
if [[ "$INSTALL_DIR" == "/usr/local/bin" ]] && [[ ! -w "$INSTALL_DIR" ]]; then
sudo rm -f "$INSTALL_DIR/mo"
else
rm -f "$INSTALL_DIR/mo"
fi
log_success "Removed mo alias"
fi
# SAFETY CHECK: Verify config directory is safe to remove
# Only allow removal of mole-specific directories
local is_safe=0
# Additional safety: never delete system critical paths (check first)
case "$CONFIG_DIR" in
/ | /usr | /usr/local | /usr/local/bin | /usr/local/lib | /usr/local/share | \
/Library | /System | /bin | /sbin | /etc | /var | /opt | "$HOME" | "$HOME/Library" | \
/usr/local/lib/* | /usr/local/share/* | /Library/* | /System/*)
is_safe=0
;;
*)
# Safe patterns: must be in user's home and end with 'mole'
if [[ "$CONFIG_DIR" == "$HOME/.config/mole" ]] ||
[[ "$CONFIG_DIR" == "$HOME"/.*/mole ]]; then
is_safe=1
fi
;;
esac
# Ask before removing config directory
if [[ -d "$CONFIG_DIR" ]]; then
if [[ $is_safe -eq 0 ]]; then
log_warning "Config directory $CONFIG_DIR is not safe to auto-remove"
log_warning "Skipping automatic removal for safety"
echo ""
echo "Please manually review and remove mole-specific files from:"
echo " $CONFIG_DIR"
else
echo ""
read -p "Remove configuration directory $CONFIG_DIR? (y/N): " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
rm -rf "$CONFIG_DIR"
log_success "Removed configuration"
else
log_success "Configuration preserved"
fi
fi
fi
echo ""
log_confirm "Mole uninstalled successfully"
}
# Main installation function
perform_install() { perform_install() {
resolve_source_dir resolve_source_dir
local source_version local source_version
@@ -517,6 +649,14 @@ perform_install() {
installed_version="$source_version" installed_version="$source_version"
fi fi
# Edge installs get a suffix to make the version explicit.
if [[ "${MOLE_EDGE_INSTALL:-}" == "true" ]]; then
installed_version="${installed_version}-edge"
echo ""
log_warning "Edge version installed on main branch"
log_info "This is a testing version; use 'mo update' to switch to stable"
fi
print_usage_summary "installed" "$installed_version" print_usage_summary "installed" "$installed_version"
} }
@@ -524,51 +664,19 @@ perform_update() {
check_requirements check_requirements
if command -v brew > /dev/null 2>&1 && brew list mole > /dev/null 2>&1; then if command -v brew > /dev/null 2>&1 && brew list mole > /dev/null 2>&1; then
# Try to use shared function if available (when running from installed Mole)
resolve_source_dir 2> /dev/null || true resolve_source_dir 2> /dev/null || true
local current_version
current_version=$(get_installed_version || echo "unknown")
if [[ -f "$SOURCE_DIR/lib/core/common.sh" ]]; then if [[ -f "$SOURCE_DIR/lib/core/common.sh" ]]; then
# shellcheck disable=SC1090,SC1091 # shellcheck disable=SC1090,SC1091
source "$SOURCE_DIR/lib/core/common.sh" source "$SOURCE_DIR/lib/core/common.sh"
update_via_homebrew "$VERSION" update_via_homebrew "$current_version"
else else
# Fallback: inline implementation log_error "Cannot update Homebrew-managed Mole without full installation"
if [[ -t 1 ]]; then echo ""
start_line_spinner "Updating Homebrew..." echo "Please update via Homebrew:"
else echo -e " ${GREEN}brew upgrade mole${NC}"
echo "Updating Homebrew..." exit 1
fi
brew update 2>&1 | grep -Ev "^(==>|Already up-to-date)" || true
if [[ -t 1 ]]; then
stop_line_spinner
fi
if [[ -t 1 ]]; then
start_line_spinner "Upgrading Mole..."
else
echo "Upgrading Mole..."
fi
local upgrade_output
upgrade_output=$(brew upgrade mole 2>&1) || true
if [[ -t 1 ]]; then
stop_line_spinner
fi
if echo "$upgrade_output" | grep -q "already installed"; then
local current_version
current_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}')
echo -e "${GREEN}${NC} Already on latest version (${current_version:-$VERSION})"
elif echo "$upgrade_output" | grep -q "Error:"; then
log_error "Homebrew upgrade failed"
echo "$upgrade_output" | grep "Error:" >&2
exit 1
else
echo "$upgrade_output" | grep -Ev "^(==>|Updating Homebrew|Warning:)" || true
local new_version
new_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}')
echo -e "${GREEN}${NC} Updated to latest version (${new_version:-$VERSION})"
fi
rm -f "$HOME/.cache/mole/version_check" "$HOME/.cache/mole/update_message"
fi fi
exit 0 exit 0
fi fi
@@ -592,11 +700,10 @@ perform_update() {
fi fi
if [[ "$installed_version" == "$target_version" ]]; then if [[ "$installed_version" == "$target_version" ]]; then
echo -e "${GREEN}${NC} Already on latest version ($installed_version)" echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version ($installed_version)"
exit 0 exit 0
fi fi
# Update with minimal output (suppress info/success, show errors only)
local old_verbose=$VERBOSE local old_verbose=$VERBOSE
VERBOSE=0 VERBOSE=0
create_directories || { create_directories || {
@@ -624,10 +731,9 @@ perform_update() {
updated_version="$target_version" updated_version="$target_version"
fi fi
echo -e "${GREEN}${NC} Updated to latest version ($updated_version)" echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version ($updated_version)"
} }
# Run requested action
parse_args "$@" parse_args "$@"
case "$ACTION" in case "$ACTION" in

View File

@@ -35,7 +35,7 @@ check_touchid_sudo() {
# Check if Touch ID is configured for sudo # Check if Touch ID is configured for sudo
local pam_file="/etc/pam.d/sudo" local pam_file="/etc/pam.d/sudo"
if [[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2> /dev/null; then if [[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2> /dev/null; then
echo -e " ${GREEN}${NC} Touch ID Enabled for sudo" echo -e " ${GREEN}${NC} Touch ID Biometric authentication enabled"
else else
# Check if Touch ID is supported # Check if Touch ID is supported
local is_supported=false local is_supported=false
@@ -48,7 +48,7 @@ check_touchid_sudo() {
fi fi
if [[ "$is_supported" == "true" ]]; then if [[ "$is_supported" == "true" ]]; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Touch ID ${YELLOW}Not configured${NC} for sudo" echo -e " ${YELLOW}${ICON_WARNING}${NC} Touch ID ${YELLOW}Not configured for sudo${NC}"
export TOUCHID_NOT_CONFIGURED=true export TOUCHID_NOT_CONFIGURED=true
fi fi
fi fi
@@ -60,9 +60,9 @@ check_rosetta() {
# Check Rosetta 2 (for Apple Silicon Macs) # Check Rosetta 2 (for Apple Silicon Macs)
if [[ "$(uname -m)" == "arm64" ]]; then if [[ "$(uname -m)" == "arm64" ]]; then
if [[ -f "/Library/Apple/usr/share/rosetta/rosetta" ]]; then if [[ -f "/Library/Apple/usr/share/rosetta/rosetta" ]]; then
echo -e " ${GREEN}${NC} Rosetta 2 Installed" echo -e " ${GREEN}${NC} Rosetta 2 Intel app translation ready"
else else
echo -e " ${YELLOW}${ICON_WARNING}${NC} Rosetta 2 ${YELLOW}Not installed${NC}" echo -e " ${YELLOW}${ICON_WARNING}${NC} Rosetta 2 ${YELLOW}Intel app support missing${NC}"
export ROSETTA_NOT_INSTALLED=true export ROSETTA_NOT_INSTALLED=true
fi fi
fi fi
@@ -77,14 +77,15 @@ check_git_config() {
local git_email=$(git config --global user.email 2> /dev/null || echo "") local git_email=$(git config --global user.email 2> /dev/null || echo "")
if [[ -n "$git_name" && -n "$git_email" ]]; then if [[ -n "$git_name" && -n "$git_email" ]]; then
echo -e " ${GREEN}${NC} Git Config Configured" echo -e " ${GREEN}${NC} Git Global identity configured"
else else
echo -e " ${YELLOW}${ICON_WARNING}${NC} Git Config ${YELLOW}Not configured${NC}" echo -e " ${YELLOW}${ICON_WARNING}${NC} Git ${YELLOW}User identity not set${NC}"
fi fi
fi fi
} }
check_all_config() { check_all_config() {
echo -e "${BLUE}${ICON_ARROW}${NC} System Configuration"
check_touchid_sudo check_touchid_sudo
check_rosetta check_rosetta
check_git_config check_git_config
@@ -101,9 +102,9 @@ check_filevault() {
if command -v fdesetup > /dev/null 2>&1; then if command -v fdesetup > /dev/null 2>&1; then
local fv_status=$(fdesetup status 2> /dev/null || echo "") local fv_status=$(fdesetup status 2> /dev/null || echo "")
if echo "$fv_status" | grep -q "FileVault is On"; then if echo "$fv_status" | grep -q "FileVault is On"; then
echo -e " ${GREEN}${NC} FileVault Enabled" echo -e " ${GREEN}${NC} FileVault Disk encryption active"
else else
echo -e " ${RED}${NC} FileVault ${RED}Disabled${NC} (Recommend enabling)" echo -e " ${RED}${NC} FileVault ${RED}Disk encryption disabled${NC}"
export FILEVAULT_DISABLED=true export FILEVAULT_DISABLED=true
fi fi
fi fi
@@ -112,15 +113,13 @@ check_filevault() {
check_firewall() { check_firewall() {
# Check whitelist # Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "firewall"; then return; fi if command -v is_whitelisted > /dev/null && is_whitelisted "firewall"; then return; fi
# Check firewall status # Check firewall status using socketfilterfw (more reliable than defaults on modern macOS)
unset FIREWALL_DISABLED unset FIREWALL_DISABLED
local firewall_status=$(defaults read /Library/Preferences/com.apple.alf globalstate 2> /dev/null || echo "0") local firewall_output=$(sudo /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2> /dev/null || echo "")
if [[ "$firewall_status" == "1" || "$firewall_status" == "2" ]]; then if [[ "$firewall_output" == *"State = 1"* ]] || [[ "$firewall_output" == *"State = 2"* ]]; then
echo -e " ${GREEN}${NC} Firewall Enabled" echo -e " ${GREEN}${NC} Firewall Network protection enabled"
else else
echo -e " ${YELLOW}${ICON_WARNING}${NC} Firewall ${YELLOW}Disabled${NC} (Consider enabling)" echo -e " ${YELLOW}${ICON_WARNING}${NC} Firewall ${YELLOW}Network protection disabled${NC}"
echo -e " ${GRAY}System Settings → Network → Firewall, or run:${NC}"
echo -e " ${GRAY}sudo defaults write /Library/Preferences/com.apple.alf globalstate -int 1${NC}"
export FIREWALL_DISABLED=true export FIREWALL_DISABLED=true
fi fi
} }
@@ -132,12 +131,10 @@ check_gatekeeper() {
if command -v spctl > /dev/null 2>&1; then if command -v spctl > /dev/null 2>&1; then
local gk_status=$(spctl --status 2> /dev/null || echo "") local gk_status=$(spctl --status 2> /dev/null || echo "")
if echo "$gk_status" | grep -q "enabled"; then if echo "$gk_status" | grep -q "enabled"; then
echo -e " ${GREEN}${NC} Gatekeeper Active" echo -e " ${GREEN}${NC} Gatekeeper App download protection active"
unset GATEKEEPER_DISABLED unset GATEKEEPER_DISABLED
else else
echo -e " ${YELLOW}${ICON_WARNING}${NC} Gatekeeper ${YELLOW}Disabled${NC}" echo -e " ${YELLOW}${ICON_WARNING}${NC} Gatekeeper ${YELLOW}App security disabled${NC}"
echo -e " ${GRAY}Enable via System Settings → Privacy & Security, or:${NC}"
echo -e " ${GRAY}sudo spctl --master-enable${NC}"
export GATEKEEPER_DISABLED=true export GATEKEEPER_DISABLED=true
fi fi
fi fi
@@ -150,15 +147,15 @@ check_sip() {
if command -v csrutil > /dev/null 2>&1; then if command -v csrutil > /dev/null 2>&1; then
local sip_status=$(csrutil status 2> /dev/null || echo "") local sip_status=$(csrutil status 2> /dev/null || echo "")
if echo "$sip_status" | grep -q "enabled"; then if echo "$sip_status" | grep -q "enabled"; then
echo -e " ${GREEN}${NC} SIP Enabled" echo -e " ${GREEN}${NC} SIP System integrity protected"
else else
echo -e " ${YELLOW}${ICON_WARNING}${NC} SIP ${YELLOW}Disabled${NC}" echo -e " ${YELLOW}${ICON_WARNING}${NC} SIP ${YELLOW}System protection disabled${NC}"
echo -e " ${GRAY}Restart into Recovery → Utilities → Terminal → run: csrutil enable${NC}"
fi fi
fi fi
} }
check_all_security() { check_all_security() {
echo -e "${BLUE}${ICON_ARROW}${NC} Security Status"
check_filevault check_filevault
check_firewall check_firewall
check_gatekeeper check_gatekeeper
@@ -174,7 +171,7 @@ CACHE_DIR="${HOME}/.cache/mole"
CACHE_TTL=600 # 10 minutes in seconds CACHE_TTL=600 # 10 minutes in seconds
# Ensure cache directory exists # Ensure cache directory exists
mkdir -p "$CACHE_DIR" 2> /dev/null || true ensure_user_dir "$CACHE_DIR"
clear_cache_file() { clear_cache_file() {
local file="$1" local file="$1"
@@ -207,68 +204,6 @@ is_cache_valid() {
[[ $cache_age -lt $ttl ]] [[ $cache_age -lt $ttl ]]
} }
check_homebrew_updates() {
# Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "check_brew_updates"; then return; fi
if ! command -v brew > /dev/null 2>&1; then
return
fi
local cache_file="$CACHE_DIR/brew_updates"
local formula_count=0
local cask_count=0
if is_cache_valid "$cache_file"; then
read -r formula_count cask_count < "$cache_file" 2> /dev/null || true
formula_count=${formula_count:-0}
cask_count=${cask_count:-0}
else
# Show spinner while checking
if [[ -t 1 ]]; then
start_inline_spinner "Checking Homebrew..."
fi
local outdated_list=""
outdated_list=$(brew outdated --quiet 2> /dev/null || echo "")
if [[ -n "$outdated_list" ]]; then
formula_count=$(echo "$outdated_list" | wc -l | tr -d ' ')
fi
local cask_list=""
cask_list=$(brew outdated --cask --quiet 2> /dev/null || echo "")
if [[ -n "$cask_list" ]]; then
cask_count=$(echo "$cask_list" | wc -l | tr -d ' ')
fi
echo "$formula_count $cask_count" > "$cache_file" 2> /dev/null || true
# Stop spinner before output
if [[ -t 1 ]]; then
stop_inline_spinner
fi
fi
local total_count=$((formula_count + cask_count))
export BREW_FORMULA_OUTDATED_COUNT=$formula_count
export BREW_CASK_OUTDATED_COUNT=$cask_count
export BREW_OUTDATED_COUNT=$total_count
if [[ $total_count -gt 0 ]]; then
local breakdown=""
if [[ $formula_count -gt 0 && $cask_count -gt 0 ]]; then
breakdown=" (${formula_count} formula, ${cask_count} cask)"
elif [[ $formula_count -gt 0 ]]; then
breakdown=" (${formula_count} formula)"
elif [[ $cask_count -gt 0 ]]; then
breakdown=" (${cask_count} cask)"
fi
echo -e " ${YELLOW}${ICON_WARNING}${NC} Homebrew ${YELLOW}${total_count} updates${NC}${breakdown}"
echo -e " ${GRAY}Run: ${GREEN}brew upgrade${NC} ${GRAY}and/or${NC} ${GREEN}brew upgrade --cask${NC}"
else
echo -e " ${GREEN}${NC} Homebrew Up to date"
fi
}
# Cache software update list to avoid calling softwareupdate twice # Cache software update list to avoid calling softwareupdate twice
SOFTWARE_UPDATE_LIST="" SOFTWARE_UPDATE_LIST=""
@@ -300,19 +235,56 @@ check_macos_update() {
local updates_available="false" local updates_available="false"
if [[ $(get_software_updates) == "Updates Available" ]]; then if [[ $(get_software_updates) == "Updates Available" ]]; then
updates_available="true" updates_available="true"
# Verify with softwareupdate using --no-scan to avoid triggering a fresh scan
# which can timeout. We prioritize avoiding false negatives (missing actual updates)
# over false positives, so we only clear the update flag when softwareupdate
# explicitly reports "No new software available"
local sw_output=""
local sw_status=0
local spinner_started=false
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking macOS updates..."
spinner_started=true
fi
local softwareupdate_timeout="${MO_SOFTWAREUPDATE_TIMEOUT:-10}"
if sw_output=$(run_with_timeout "$softwareupdate_timeout" softwareupdate -l --no-scan 2> /dev/null); then
:
else
sw_status=$?
fi
if [[ "$spinner_started" == "true" ]]; then
stop_inline_spinner
fi
# Debug logging for troubleshooting
if [[ -n "${MO_DEBUG:-}" ]]; then
echo "[DEBUG] softwareupdate exit status: $sw_status, output lines: $(echo "$sw_output" | wc -l | tr -d ' ')" >&2
fi
# Prefer avoiding false negatives: if the system indicates updates are pending,
# only clear the flag when softwareupdate returns a list without any update entries.
if [[ $sw_status -eq 0 && -n "$sw_output" ]]; then
if ! echo "$sw_output" | grep -qE '^[[:space:]]*\*'; then
updates_available="false"
fi
fi
fi fi
export MACOS_UPDATE_AVAILABLE="$updates_available" export MACOS_UPDATE_AVAILABLE="$updates_available"
if [[ "$updates_available" == "true" ]]; then if [[ "$updates_available" == "true" ]]; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS ${YELLOW}Update available${NC}" echo -e " ${YELLOW}${ICON_WARNING}${NC} macOS ${YELLOW}Update available${NC}"
echo -e " ${GRAY}update available in final step${NC}"
else else
echo -e " ${GREEN}${NC} macOS Up to date" echo -e " ${GREEN}${NC} macOS System up to date"
fi fi
} }
check_mole_update() { check_mole_update() {
if command -v is_whitelisted > /dev/null && is_whitelisted "check_mole_update"; then return; fi
# Check if Mole has updates # Check if Mole has updates
# Auto-detect version from mole main script # Auto-detect version from mole main script
local current_version local current_version
@@ -333,16 +305,27 @@ check_mole_update() {
else else
# Show spinner while checking # Show spinner while checking
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
start_inline_spinner "Checking Mole version..." MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Mole version..."
fi fi
# Try to get latest version from GitHub # Try to get latest version from GitHub
if command -v curl > /dev/null 2>&1; then if command -v curl > /dev/null 2>&1; then
latest_version=$(curl -fsSL https://api.github.com/repos/tw93/mole/releases/latest 2> /dev/null | grep '"tag_name"' | sed -E 's/.*"v?([^"]+)".*/\1/' || echo "") # Run in background to allow Ctrl+C to interrupt
# Save to cache local temp_version
if [[ -n "$latest_version" ]]; then temp_version=$(mktemp_file "mole_version_check")
echo "$latest_version" > "$cache_file" 2> /dev/null || true curl -fsSL --connect-timeout 3 --max-time 5 https://api.github.com/repos/tw93/mole/releases/latest 2> /dev/null | grep '"tag_name"' | sed -E 's/.*"v?([^"]+)".*/\1/' > "$temp_version" &
local curl_pid=$!
# Wait for curl to complete (allows Ctrl+C to interrupt)
if wait "$curl_pid" 2> /dev/null; then
latest_version=$(cat "$temp_version" 2> /dev/null || echo "")
# Save to cache
if [[ -n "$latest_version" ]]; then
ensure_user_file "$cache_file"
echo "$latest_version" > "$cache_file" 2> /dev/null || true
fi
fi fi
rm -f "$temp_version" 2> /dev/null || true
fi fi
# Stop spinner # Stop spinner
@@ -361,13 +344,12 @@ check_mole_update() {
# Compare versions # Compare versions
if [[ "$(printf '%s\n' "$current_version" "$latest_version" | sort -V | head -1)" == "$current_version" ]]; then if [[ "$(printf '%s\n' "$current_version" "$latest_version" | sort -V | head -1)" == "$current_version" ]]; then
export MOLE_UPDATE_AVAILABLE="true" export MOLE_UPDATE_AVAILABLE="true"
echo -e " ${YELLOW}${ICON_WARNING}${NC} Mole ${YELLOW}${latest_version} available${NC} (current: ${current_version})" echo -e " ${YELLOW}${ICON_WARNING}${NC} Mole ${YELLOW}${latest_version} available${NC} (running ${current_version})"
echo -e " ${GRAY}Run: ${GREEN}mo update${NC}"
else else
echo -e " ${GREEN}${NC} Mole Up to date (${current_version})" echo -e " ${GREEN}${NC} Mole Latest version ${current_version}"
fi fi
else else
echo -e " ${GREEN}${NC} Mole Up to date (${current_version})" echo -e " ${GREEN}${NC} Mole Latest version ${current_version}"
fi fi
} }
@@ -375,12 +357,11 @@ check_all_updates() {
# Reset spinner flag for softwareupdate # Reset spinner flag for softwareupdate
unset SOFTWAREUPDATE_SPINNER_SHOWN unset SOFTWAREUPDATE_SPINNER_SHOWN
check_homebrew_updates
# Preload software update data to avoid delays between subsequent checks # Preload software update data to avoid delays between subsequent checks
# Only redirect stdout, keep stderr for spinner display # Only redirect stdout, keep stderr for spinner display
get_software_updates > /dev/null get_software_updates > /dev/null
echo -e "${BLUE}${ICON_ARROW}${NC} System Updates"
check_appstore_updates check_appstore_updates
check_macos_update check_macos_update
check_mole_update check_mole_update
@@ -488,7 +469,7 @@ check_login_items() {
if [[ -t 0 ]]; then if [[ -t 0 ]]; then
# Show spinner while getting login items # Show spinner while getting login items
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
start_inline_spinner "Checking login items..." MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking login items..."
fi fi
while IFS= read -r login_item; do while IFS= read -r login_item; do
@@ -503,16 +484,16 @@ check_login_items() {
fi fi
if [[ $login_items_count -gt 15 ]]; then if [[ $login_items_count -gt 15 ]]; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Login Items ${YELLOW}${login_items_count} apps${NC} auto-start (High)" echo -e " ${YELLOW}${ICON_WARNING}${NC} Login Items ${YELLOW}${login_items_count} apps${NC}"
elif [[ $login_items_count -gt 0 ]]; then elif [[ $login_items_count -gt 0 ]]; then
echo -e " ${GREEN}${NC} Login Items ${login_items_count} apps auto-start" echo -e " ${GREEN}${NC} Login Items ${login_items_count} apps"
else else
echo -e " ${GREEN}${NC} Login Items None" echo -e " ${GREEN}${NC} Login Items None"
return return
fi fi
# Show items in a single line # Show items in a single line (compact)
local preview_limit=5 local preview_limit=3
((preview_limit > login_items_count)) && preview_limit=$login_items_count ((preview_limit > login_items_count)) && preview_limit=$login_items_count
local items_display="" local items_display=""
@@ -526,11 +507,10 @@ check_login_items() {
if ((login_items_count > preview_limit)); then if ((login_items_count > preview_limit)); then
local remaining=$((login_items_count - preview_limit)) local remaining=$((login_items_count - preview_limit))
items_display="${items_display}, and ${remaining} more" items_display="${items_display} +${remaining}"
fi fi
echo -e " ${GRAY}${items_display}${NC}" echo -e " ${GRAY}${items_display}${NC}"
echo -e " ${GRAY}Manage in System Settings → Login Items${NC}"
} }
check_cache_size() { check_cache_size() {
@@ -544,7 +524,7 @@ check_cache_size() {
# Show spinner while calculating cache size # Show spinner while calculating cache size
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
start_inline_spinner "Scanning cache..." MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning cache..."
fi fi
for cache_path in "${cache_paths[@]}"; do for cache_path in "${cache_paths[@]}"; do
@@ -581,7 +561,8 @@ check_swap_usage() {
if command -v sysctl > /dev/null 2>&1; then if command -v sysctl > /dev/null 2>&1; then
local swap_info=$(sysctl vm.swapusage 2> /dev/null || echo "") local swap_info=$(sysctl vm.swapusage 2> /dev/null || echo "")
if [[ -n "$swap_info" ]]; then if [[ -n "$swap_info" ]]; then
local swap_used=$(echo "$swap_info" | grep -o "used = [0-9.]*[GM]" | awk '{print $3}' || echo "0M") local swap_used=$(echo "$swap_info" | grep -o "used = [0-9.]*[GM]" | awk 'NR==1{print $3}')
swap_used=${swap_used:-0M}
local swap_num="${swap_used//[GM]/}" local swap_num="${swap_used//[GM]/}"
if [[ "$swap_used" == *"G"* ]]; then if [[ "$swap_used" == *"G"* ]]; then
@@ -601,19 +582,14 @@ check_swap_usage() {
check_brew_health() { check_brew_health() {
# Check whitelist # Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "check_brew_health"; then return; fi if command -v is_whitelisted > /dev/null && is_whitelisted "check_brew_health"; then return; fi
# Check Homebrew status (fast)
if command -v brew > /dev/null 2>&1; then
# Skip slow 'brew doctor' check by default
echo -e " ${GREEN}${NC} Homebrew Installed"
fi
} }
check_system_health() { check_system_health() {
echo -e "${BLUE}${ICON_ARROW}${NC} System Health"
check_disk_space check_disk_space
check_memory_usage check_memory_usage
check_swap_usage check_swap_usage
check_login_items check_login_items
check_cache_size check_cache_size
# Time Machine check is optional; skip by default to avoid noise on systems without backups # Time Machine check is optional; skip by default to avoid noise on systems without backups
check_brew_health
} }

View File

@@ -24,9 +24,9 @@ get_memory_info() {
vm_output=$(vm_stat 2> /dev/null || echo "") vm_output=$(vm_stat 2> /dev/null || echo "")
page_size=4096 page_size=4096
active=$(echo "$vm_output" | LC_ALL=C awk '/Pages active:/ {print $NF}' | tr -d '.' 2> /dev/null || echo "0") active=$(echo "$vm_output" | LC_ALL=C awk '/Pages active:/ {print $NF}' | tr -d '.\n' 2> /dev/null)
wired=$(echo "$vm_output" | LC_ALL=C awk '/Pages wired down:/ {print $NF}' | tr -d '.' 2> /dev/null || echo "0") wired=$(echo "$vm_output" | LC_ALL=C awk '/Pages wired down:/ {print $NF}' | tr -d '.\n' 2> /dev/null)
compressed=$(echo "$vm_output" | LC_ALL=C awk '/Pages occupied by compressor:/ {print $NF}' | tr -d '.' 2> /dev/null || echo "0") compressed=$(echo "$vm_output" | LC_ALL=C awk '/Pages occupied by compressor:/ {print $NF}' | tr -d '.\n' 2> /dev/null)
active=${active:-0} active=${active:-0}
wired=${wired:-0} wired=${wired:-0}
@@ -47,8 +47,8 @@ get_disk_info() {
df_output=$(command df -k "$home" 2> /dev/null | tail -1) df_output=$(command df -k "$home" 2> /dev/null | tail -1)
local total_kb used_kb local total_kb used_kb
total_kb=$(echo "$df_output" | LC_ALL=C awk '{print $2}' 2> /dev/null || echo "0") total_kb=$(echo "$df_output" | LC_ALL=C awk 'NR==1{print $2}' 2> /dev/null)
used_kb=$(echo "$df_output" | LC_ALL=C awk '{print $3}' 2> /dev/null || echo "0") used_kb=$(echo "$df_output" | LC_ALL=C awk 'NR==1{print $3}' 2> /dev/null)
total_kb=${total_kb:-0} total_kb=${total_kb:-0}
used_kb=${used_kb:-0} used_kb=${used_kb:-0}
@@ -122,16 +122,30 @@ EOF
# Collect all optimization items # Collect all optimization items
local -a items=() local -a items=()
# Always-on items (no size checks - instant) # Core optimizations (safe and valuable)
items+=('system_maintenance|System Maintenance|Rebuild system databases & flush caches|true') items+=('system_maintenance|DNS & Spotlight Check|Refresh DNS cache & verify Spotlight status|true')
items+=('maintenance_scripts|Maintenance Scripts|Rotate system logs|true') items+=('cache_refresh|Finder Cache Refresh|Refresh QuickLook thumbnails & icon services cache|true')
items+=('recent_items|Recent Items|Clear recent apps/documents/servers lists|true') items+=('saved_state_cleanup|App State Cleanup|Remove old saved application states (30+ days)|true')
items+=('log_cleanup|Diagnostics Cleanup|Purge old diagnostic & crash logs|true') items+=('fix_broken_configs|Broken Config Repair|Fix corrupted preferences files|true')
items+=('mail_downloads|Mail Downloads|Clear old mail attachments (> 30 days)|true') items+=('network_optimization|Network Cache Refresh|Optimize DNS cache & restart mDNSResponder|true')
items+=('swap_cleanup|Swap Refresh|Reset swap files and dynamic pager|true')
items+=('spotlight_cache_cleanup|Spotlight Cache|Clear user-level Spotlight indexes|true') # Advanced optimizations (high value, auto-run with safety checks)
items+=('developer_cleanup|Developer Cleanup|Clear Xcode DerivedData & DeviceSupport|true') items+=('sqlite_vacuum|Database Optimization|Compress SQLite databases for Mail, Safari & Messages (skips if apps are running)|true')
items+=('network_optimization|Network Optimization|Flush DNS, ARP & reset mDNS|true') items+=('launch_services_rebuild|LaunchServices Repair|Repair "Open with" menu & file associations|true')
items+=('font_cache_rebuild|Font Cache Rebuild|Rebuild font database to fix rendering issues|true')
items+=('dock_refresh|Dock Refresh|Fix broken icons and visual glitches in the Dock|true')
# System performance optimizations (new)
items+=('memory_pressure_relief|Memory Optimization|Release inactive memory to improve system responsiveness|true')
items+=('network_stack_optimize|Network Stack Refresh|Flush routing table and ARP cache to resolve network issues|true')
items+=('disk_permissions_repair|Permission Repair|Fix user directory permission issues|true')
items+=('bluetooth_reset|Bluetooth Refresh|Restart Bluetooth module to fix connectivity (skips if in use)|true')
items+=('spotlight_index_optimize|Spotlight Optimization|Rebuild index if search is slow (smart detection)|true')
# Removed high-risk optimizations:
# - startup_items_cleanup: Risk of deleting legitimate app helpers
# - system_services_refresh: Risk of data loss when killing system services
# - dyld_cache_update: Low benefit, time-consuming, auto-managed by macOS
# Output items as JSON # Output items as JSON
local first=true local first=true

View File

@@ -1,29 +1,19 @@
#!/bin/bash #!/bin/bash
# User GUI Applications Cleanup Module # User GUI Applications Cleanup Module (desktop apps, media, utilities).
# Desktop applications, communication tools, media players, games, utilities
set -euo pipefail set -euo pipefail
# Xcode and iOS tooling.
# Clean Xcode and iOS development tools
# Archives can be significant in size (app packaging files)
# DeviceSupport files for old iOS versions can accumulate
# Note: Skips critical files if Xcode is running
clean_xcode_tools() { clean_xcode_tools() {
# Check if Xcode is running for safer cleanup of critical resources # Skip DerivedData/Archives while Xcode is running.
local xcode_running=false local xcode_running=false
if pgrep -x "Xcode" > /dev/null 2>&1; then if pgrep -x "Xcode" > /dev/null 2>&1; then
xcode_running=true xcode_running=true
fi fi
# Safe to clean regardless of Xcode state
safe_clean ~/Library/Developer/CoreSimulator/Caches/* "Simulator cache" safe_clean ~/Library/Developer/CoreSimulator/Caches/* "Simulator cache"
safe_clean ~/Library/Developer/CoreSimulator/Devices/*/data/tmp/* "Simulator temp files" safe_clean ~/Library/Developer/CoreSimulator/Devices/*/data/tmp/* "Simulator temp files"
safe_clean ~/Library/Caches/com.apple.dt.Xcode/* "Xcode cache" safe_clean ~/Library/Caches/com.apple.dt.Xcode/* "Xcode cache"
safe_clean ~/Library/Developer/Xcode/iOS\ Device\ Logs/* "iOS device logs" safe_clean ~/Library/Developer/Xcode/iOS\ Device\ Logs/* "iOS device logs"
safe_clean ~/Library/Developer/Xcode/watchOS\ Device\ Logs/* "watchOS device logs" safe_clean ~/Library/Developer/Xcode/watchOS\ Device\ Logs/* "watchOS device logs"
safe_clean ~/Library/Developer/Xcode/Products/* "Xcode build products" safe_clean ~/Library/Developer/Xcode/Products/* "Xcode build products"
# Clean build artifacts only if Xcode is not running
if [[ "$xcode_running" == "false" ]]; then if [[ "$xcode_running" == "false" ]]; then
safe_clean ~/Library/Developer/Xcode/DerivedData/* "Xcode derived data" safe_clean ~/Library/Developer/Xcode/DerivedData/* "Xcode derived data"
safe_clean ~/Library/Developer/Xcode/Archives/* "Xcode archives" safe_clean ~/Library/Developer/Xcode/Archives/* "Xcode archives"
@@ -31,20 +21,18 @@ clean_xcode_tools() {
echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode is running, skipping DerivedData and Archives cleanup" echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode is running, skipping DerivedData and Archives cleanup"
fi fi
} }
# Code editors.
# Clean code editors (VS Code, Sublime, etc.)
clean_code_editors() { clean_code_editors() {
safe_clean ~/Library/Application\ Support/Code/logs/* "VS Code logs" safe_clean ~/Library/Application\ Support/Code/logs/* "VS Code logs"
safe_clean ~/Library/Application\ Support/Code/Cache/* "VS Code cache" safe_clean ~/Library/Application\ Support/Code/Cache/* "VS Code cache"
safe_clean ~/Library/Application\ Support/Code/CachedExtensions/* "VS Code extension cache" safe_clean ~/Library/Application\ Support/Code/CachedExtensions/* "VS Code extension cache"
safe_clean ~/Library/Application\ Support/Code/CachedData/* "VS Code data cache" safe_clean ~/Library/Application\ Support/Code/CachedData/* "VS Code data cache"
# safe_clean ~/Library/Caches/JetBrains/* "JetBrains cache"
safe_clean ~/Library/Caches/com.sublimetext.*/* "Sublime Text cache" safe_clean ~/Library/Caches/com.sublimetext.*/* "Sublime Text cache"
} }
# Communication apps.
# Clean communication apps (Slack, Discord, Zoom, etc.)
clean_communication_apps() { clean_communication_apps() {
safe_clean ~/Library/Application\ Support/discord/Cache/* "Discord cache" safe_clean ~/Library/Application\ Support/discord/Cache/* "Discord cache"
safe_clean ~/Library/Application\ Support/legcord/Cache/* "Legcord cache"
safe_clean ~/Library/Application\ Support/Slack/Cache/* "Slack cache" safe_clean ~/Library/Application\ Support/Slack/Cache/* "Slack cache"
safe_clean ~/Library/Caches/us.zoom.xos/* "Zoom cache" safe_clean ~/Library/Caches/us.zoom.xos/* "Zoom cache"
safe_clean ~/Library/Caches/com.tencent.xinWeChat/* "WeChat cache" safe_clean ~/Library/Caches/com.tencent.xinWeChat/* "WeChat cache"
@@ -56,49 +44,43 @@ clean_communication_apps() {
safe_clean ~/Library/Caches/com.tencent.WeWorkMac/* "WeCom cache" safe_clean ~/Library/Caches/com.tencent.WeWorkMac/* "WeCom cache"
safe_clean ~/Library/Caches/com.feishu.*/* "Feishu cache" safe_clean ~/Library/Caches/com.feishu.*/* "Feishu cache"
} }
# DingTalk.
# Clean DingTalk
clean_dingtalk() { clean_dingtalk() {
safe_clean ~/Library/Caches/dd.work.exclusive4aliding/* "DingTalk (iDingTalk) cache" safe_clean ~/Library/Caches/dd.work.exclusive4aliding/* "DingTalk iDingTalk cache"
safe_clean ~/Library/Caches/com.alibaba.AliLang.osx/* "AliLang security component" safe_clean ~/Library/Caches/com.alibaba.AliLang.osx/* "AliLang security component"
safe_clean ~/Library/Application\ Support/iDingTalk/log/* "DingTalk logs" safe_clean ~/Library/Application\ Support/iDingTalk/log/* "DingTalk logs"
safe_clean ~/Library/Application\ Support/iDingTalk/holmeslogs/* "DingTalk holmes logs" safe_clean ~/Library/Application\ Support/iDingTalk/holmeslogs/* "DingTalk holmes logs"
} }
# AI assistants.
# Clean AI assistants
clean_ai_apps() { clean_ai_apps() {
safe_clean ~/Library/Caches/com.openai.chat/* "ChatGPT cache" safe_clean ~/Library/Caches/com.openai.chat/* "ChatGPT cache"
safe_clean ~/Library/Caches/com.anthropic.claudefordesktop/* "Claude desktop cache" safe_clean ~/Library/Caches/com.anthropic.claudefordesktop/* "Claude desktop cache"
safe_clean ~/Library/Logs/Claude/* "Claude logs" safe_clean ~/Library/Logs/Claude/* "Claude logs"
} }
# Design and creative tools.
# Clean design and creative tools
clean_design_tools() { clean_design_tools() {
safe_clean ~/Library/Caches/com.bohemiancoding.sketch3/* "Sketch cache" safe_clean ~/Library/Caches/com.bohemiancoding.sketch3/* "Sketch cache"
safe_clean ~/Library/Application\ Support/com.bohemiancoding.sketch3/cache/* "Sketch app cache" safe_clean ~/Library/Application\ Support/com.bohemiancoding.sketch3/cache/* "Sketch app cache"
safe_clean ~/Library/Caches/Adobe/* "Adobe cache" safe_clean ~/Library/Caches/Adobe/* "Adobe cache"
safe_clean ~/Library/Caches/com.adobe.*/* "Adobe app caches" safe_clean ~/Library/Caches/com.adobe.*/* "Adobe app caches"
safe_clean ~/Library/Caches/com.figma.Desktop/* "Figma cache" safe_clean ~/Library/Caches/com.figma.Desktop/* "Figma cache"
safe_clean ~/Library/Caches/com.raycast.macos/* "Raycast cache" # Raycast cache is protected (clipboard history, images).
} }
# Video editing tools.
# Clean video editing tools
clean_video_tools() { clean_video_tools() {
safe_clean ~/Library/Caches/net.telestream.screenflow10/* "ScreenFlow cache" safe_clean ~/Library/Caches/net.telestream.screenflow10/* "ScreenFlow cache"
safe_clean ~/Library/Caches/com.apple.FinalCut/* "Final Cut Pro cache" safe_clean ~/Library/Caches/com.apple.FinalCut/* "Final Cut Pro cache"
safe_clean ~/Library/Caches/com.blackmagic-design.DaVinciResolve/* "DaVinci Resolve cache" safe_clean ~/Library/Caches/com.blackmagic-design.DaVinciResolve/* "DaVinci Resolve cache"
safe_clean ~/Library/Caches/com.adobe.PremierePro.*/* "Premiere Pro cache" safe_clean ~/Library/Caches/com.adobe.PremierePro.*/* "Premiere Pro cache"
} }
# 3D and CAD tools.
# Clean 3D and CAD tools
clean_3d_tools() { clean_3d_tools() {
safe_clean ~/Library/Caches/org.blenderfoundation.blender/* "Blender cache" safe_clean ~/Library/Caches/org.blenderfoundation.blender/* "Blender cache"
safe_clean ~/Library/Caches/com.maxon.cinema4d/* "Cinema 4D cache" safe_clean ~/Library/Caches/com.maxon.cinema4d/* "Cinema 4D cache"
safe_clean ~/Library/Caches/com.autodesk.*/* "Autodesk cache" safe_clean ~/Library/Caches/com.autodesk.*/* "Autodesk cache"
safe_clean ~/Library/Caches/com.sketchup.*/* "SketchUp cache" safe_clean ~/Library/Caches/com.sketchup.*/* "SketchUp cache"
} }
# Productivity apps.
# Clean productivity apps
clean_productivity_apps() { clean_productivity_apps() {
safe_clean ~/Library/Caches/com.tw93.MiaoYan/* "MiaoYan cache" safe_clean ~/Library/Caches/com.tw93.MiaoYan/* "MiaoYan cache"
safe_clean ~/Library/Caches/com.klee.desktop/* "Klee cache" safe_clean ~/Library/Caches/com.klee.desktop/* "Klee cache"
@@ -107,31 +89,24 @@ clean_productivity_apps() {
safe_clean ~/Library/Caches/com.filo.client/* "Filo cache" safe_clean ~/Library/Caches/com.filo.client/* "Filo cache"
safe_clean ~/Library/Caches/com.flomoapp.mac/* "Flomo cache" safe_clean ~/Library/Caches/com.flomoapp.mac/* "Flomo cache"
} }
# Music/media players (protect Spotify offline music).
# Clean music and media players
# Note: Spotify cache is protected by default (may contain offline music)
# Users can override via whitelist settings
clean_media_players() { clean_media_players() {
# Spotify cache protection: check for offline music indicators
local spotify_cache="$HOME/Library/Caches/com.spotify.client" local spotify_cache="$HOME/Library/Caches/com.spotify.client"
local spotify_data="$HOME/Library/Application Support/Spotify" local spotify_data="$HOME/Library/Application Support/Spotify"
local has_offline_music=false local has_offline_music=false
# Heuristics: offline DB or large cache.
# Check for offline music database or large cache (>500MB)
if [[ -f "$spotify_data/PersistentCache/Storage/offline.bnk" ]] || if [[ -f "$spotify_data/PersistentCache/Storage/offline.bnk" ]] ||
[[ -d "$spotify_data/PersistentCache/Storage" && -n "$(find "$spotify_data/PersistentCache/Storage" -type f -name "*.file" 2> /dev/null | head -1)" ]]; then [[ -d "$spotify_data/PersistentCache/Storage" && -n "$(find "$spotify_data/PersistentCache/Storage" -type f -name "*.file" 2> /dev/null | head -1)" ]]; then
has_offline_music=true has_offline_music=true
elif [[ -d "$spotify_cache" ]]; then elif [[ -d "$spotify_cache" ]]; then
local cache_size_kb local cache_size_kb
cache_size_kb=$(get_path_size_kb "$spotify_cache") cache_size_kb=$(get_path_size_kb "$spotify_cache")
# Large cache (>500MB) likely contains offline music
if [[ $cache_size_kb -ge 512000 ]]; then if [[ $cache_size_kb -ge 512000 ]]; then
has_offline_music=true has_offline_music=true
fi fi
fi fi
if [[ "$has_offline_music" == "true" ]]; then if [[ "$has_offline_music" == "true" ]]; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Spotify cache protected (offline music detected)" echo -e " ${YELLOW}${ICON_WARNING}${NC} Spotify cache protected · offline music detected"
note_activity note_activity
else else
safe_clean ~/Library/Caches/com.spotify.client/* "Spotify cache" safe_clean ~/Library/Caches/com.spotify.client/* "Spotify cache"
@@ -145,8 +120,7 @@ clean_media_players() {
safe_clean ~/Library/Caches/com.kugou.mac/* "Kugou Music cache" safe_clean ~/Library/Caches/com.kugou.mac/* "Kugou Music cache"
safe_clean ~/Library/Caches/com.kuwo.mac/* "Kuwo Music cache" safe_clean ~/Library/Caches/com.kuwo.mac/* "Kuwo Music cache"
} }
# Video players.
# Clean video players
clean_video_players() { clean_video_players() {
safe_clean ~/Library/Caches/com.colliderli.iina "IINA cache" safe_clean ~/Library/Caches/com.colliderli.iina "IINA cache"
safe_clean ~/Library/Caches/org.videolan.vlc "VLC cache" safe_clean ~/Library/Caches/org.videolan.vlc "VLC cache"
@@ -157,8 +131,7 @@ clean_video_players() {
safe_clean ~/Library/Caches/com.douyu.*/* "Douyu cache" safe_clean ~/Library/Caches/com.douyu.*/* "Douyu cache"
safe_clean ~/Library/Caches/com.huya.*/* "Huya cache" safe_clean ~/Library/Caches/com.huya.*/* "Huya cache"
} }
# Download managers.
# Clean download managers
clean_download_managers() { clean_download_managers() {
safe_clean ~/Library/Caches/net.xmac.aria2gui "Aria2 cache" safe_clean ~/Library/Caches/net.xmac.aria2gui "Aria2 cache"
safe_clean ~/Library/Caches/org.m0k.transmission "Transmission cache" safe_clean ~/Library/Caches/org.m0k.transmission "Transmission cache"
@@ -167,8 +140,7 @@ clean_download_managers() {
safe_clean ~/Library/Caches/com.folx.*/* "Folx cache" safe_clean ~/Library/Caches/com.folx.*/* "Folx cache"
safe_clean ~/Library/Caches/com.charlessoft.pacifist/* "Pacifist cache" safe_clean ~/Library/Caches/com.charlessoft.pacifist/* "Pacifist cache"
} }
# Gaming platforms.
# Clean gaming platforms
clean_gaming_platforms() { clean_gaming_platforms() {
safe_clean ~/Library/Caches/com.valvesoftware.steam/* "Steam cache" safe_clean ~/Library/Caches/com.valvesoftware.steam/* "Steam cache"
safe_clean ~/Library/Application\ Support/Steam/htmlcache/* "Steam web cache" safe_clean ~/Library/Application\ Support/Steam/htmlcache/* "Steam web cache"
@@ -179,48 +151,41 @@ clean_gaming_platforms() {
safe_clean ~/Library/Caches/com.gog.galaxy/* "GOG Galaxy cache" safe_clean ~/Library/Caches/com.gog.galaxy/* "GOG Galaxy cache"
safe_clean ~/Library/Caches/com.riotgames.*/* "Riot Games cache" safe_clean ~/Library/Caches/com.riotgames.*/* "Riot Games cache"
} }
# Translation/dictionary apps.
# Clean translation and dictionary apps
clean_translation_apps() { clean_translation_apps() {
safe_clean ~/Library/Caches/com.youdao.YoudaoDict "Youdao Dictionary cache" safe_clean ~/Library/Caches/com.youdao.YoudaoDict "Youdao Dictionary cache"
safe_clean ~/Library/Caches/com.eudic.* "Eudict cache" safe_clean ~/Library/Caches/com.eudic.* "Eudict cache"
safe_clean ~/Library/Caches/com.bob-build.Bob "Bob Translation cache" safe_clean ~/Library/Caches/com.bob-build.Bob "Bob Translation cache"
} }
# Screenshot/recording tools.
# Clean screenshot and screen recording tools
clean_screenshot_tools() { clean_screenshot_tools() {
safe_clean ~/Library/Caches/com.cleanshot.* "CleanShot cache" safe_clean ~/Library/Caches/com.cleanshot.* "CleanShot cache"
safe_clean ~/Library/Caches/com.reincubate.camo "Camo cache" safe_clean ~/Library/Caches/com.reincubate.camo "Camo cache"
safe_clean ~/Library/Caches/com.xnipapp.xnip "Xnip cache" safe_clean ~/Library/Caches/com.xnipapp.xnip "Xnip cache"
} }
# Email clients.
# Clean email clients
clean_email_clients() { clean_email_clients() {
safe_clean ~/Library/Caches/com.readdle.smartemail-Mac "Spark cache" safe_clean ~/Library/Caches/com.readdle.smartemail-Mac "Spark cache"
safe_clean ~/Library/Caches/com.airmail.* "Airmail cache" safe_clean ~/Library/Caches/com.airmail.* "Airmail cache"
} }
# Task management apps.
# Clean task management apps
clean_task_apps() { clean_task_apps() {
safe_clean ~/Library/Caches/com.todoist.mac.Todoist "Todoist cache" safe_clean ~/Library/Caches/com.todoist.mac.Todoist "Todoist cache"
safe_clean ~/Library/Caches/com.any.do.* "Any.do cache" safe_clean ~/Library/Caches/com.any.do.* "Any.do cache"
} }
# Shell/terminal utilities.
# Clean shell and terminal utilities
clean_shell_utils() { clean_shell_utils() {
safe_clean ~/.zcompdump* "Zsh completion cache" safe_clean ~/.zcompdump* "Zsh completion cache"
safe_clean ~/.lesshst "less history" safe_clean ~/.lesshst "less history"
safe_clean ~/.viminfo.tmp "Vim temporary files" safe_clean ~/.viminfo.tmp "Vim temporary files"
safe_clean ~/.wget-hsts "wget HSTS cache" safe_clean ~/.wget-hsts "wget HSTS cache"
} }
# Input methods and system utilities.
# Clean input method and system utilities
clean_system_utils() { clean_system_utils() {
safe_clean ~/Library/Caches/com.runjuu.Input-Source-Pro/* "Input Source Pro cache" safe_clean ~/Library/Caches/com.runjuu.Input-Source-Pro/* "Input Source Pro cache"
safe_clean ~/Library/Caches/macos-wakatime.WakaTime/* "WakaTime cache" safe_clean ~/Library/Caches/macos-wakatime.WakaTime/* "WakaTime cache"
} }
# Note-taking apps.
# Clean note-taking apps
clean_note_apps() { clean_note_apps() {
safe_clean ~/Library/Caches/notion.id/* "Notion cache" safe_clean ~/Library/Caches/notion.id/* "Notion cache"
safe_clean ~/Library/Caches/md.obsidian/* "Obsidian cache" safe_clean ~/Library/Caches/md.obsidian/* "Obsidian cache"
@@ -229,23 +194,21 @@ clean_note_apps() {
safe_clean ~/Library/Caches/com.evernote.*/* "Evernote cache" safe_clean ~/Library/Caches/com.evernote.*/* "Evernote cache"
safe_clean ~/Library/Caches/com.yinxiang.*/* "Yinxiang Note cache" safe_clean ~/Library/Caches/com.yinxiang.*/* "Yinxiang Note cache"
} }
# Launchers and automation tools.
# Clean launcher and automation tools
clean_launcher_apps() { clean_launcher_apps() {
safe_clean ~/Library/Caches/com.runningwithcrayons.Alfred/* "Alfred cache" safe_clean ~/Library/Caches/com.runningwithcrayons.Alfred/* "Alfred cache"
safe_clean ~/Library/Caches/cx.c3.theunarchiver/* "The Unarchiver cache" safe_clean ~/Library/Caches/cx.c3.theunarchiver/* "The Unarchiver cache"
} }
# Remote desktop tools.
# Clean remote desktop tools
clean_remote_desktop() { clean_remote_desktop() {
safe_clean ~/Library/Caches/com.teamviewer.*/* "TeamViewer cache" safe_clean ~/Library/Caches/com.teamviewer.*/* "TeamViewer cache"
safe_clean ~/Library/Caches/com.anydesk.*/* "AnyDesk cache" safe_clean ~/Library/Caches/com.anydesk.*/* "AnyDesk cache"
safe_clean ~/Library/Caches/com.todesk.*/* "ToDesk cache" safe_clean ~/Library/Caches/com.todesk.*/* "ToDesk cache"
safe_clean ~/Library/Caches/com.sunlogin.*/* "Sunlogin cache" safe_clean ~/Library/Caches/com.sunlogin.*/* "Sunlogin cache"
} }
# Main entry for GUI app cleanup.
# Main function to clean all user GUI applications
clean_user_gui_applications() { clean_user_gui_applications() {
stop_section_spinner
clean_xcode_tools clean_xcode_tools
clean_code_editors clean_code_editors
clean_communication_apps clean_communication_apps

View File

@@ -1,27 +1,19 @@
#!/bin/bash #!/bin/bash
# Application Data Cleanup Module # Application Data Cleanup Module
set -euo pipefail set -euo pipefail
# Clean .DS_Store (Finder metadata), home uses maxdepth 5, excludes slow paths, max 500 files
# Args: $1=target_dir, $2=label # Args: $1=target_dir, $2=label
clean_ds_store_tree() { clean_ds_store_tree() {
local target="$1" local target="$1"
local label="$2" local label="$2"
[[ -d "$target" ]] || return 0 [[ -d "$target" ]] || return 0
local file_count=0 local file_count=0
local total_bytes=0 local total_bytes=0
local spinner_active="false" local spinner_active="false"
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" " MOLE_SPINNER_PREFIX=" "
start_inline_spinner "Cleaning Finder metadata..." start_inline_spinner "Cleaning Finder metadata..."
spinner_active="true" spinner_active="true"
fi fi
# Build exclusion paths for find (skip common slow/large directories)
local -a exclude_paths=( local -a exclude_paths=(
-path "*/Library/Application Support/MobileSync" -prune -o -path "*/Library/Application Support/MobileSync" -prune -o
-path "*/Library/Developer" -prune -o -path "*/Library/Developer" -prune -o
@@ -30,15 +22,11 @@ clean_ds_store_tree() {
-path "*/.git" -prune -o -path "*/.git" -prune -o
-path "*/Library/Caches" -prune -o -path "*/Library/Caches" -prune -o
) )
# Build find command to avoid unbound array expansion with set -u
local -a find_cmd=("command" "find" "$target") local -a find_cmd=("command" "find" "$target")
if [[ "$target" == "$HOME" ]]; then if [[ "$target" == "$HOME" ]]; then
find_cmd+=("-maxdepth" "5") find_cmd+=("-maxdepth" "5")
fi fi
find_cmd+=("${exclude_paths[@]}" "-type" "f" "-name" ".DS_Store" "-print0") find_cmd+=("${exclude_paths[@]}" "-type" "f" "-name" ".DS_Store" "-print0")
# Find .DS_Store files with exclusions and depth limit
while IFS= read -r -d '' ds_file; do while IFS= read -r -d '' ds_file; do
local size local size
size=$(get_file_size "$ds_file") size=$(get_file_size "$ds_file")
@@ -47,27 +35,21 @@ clean_ds_store_tree() {
if [[ "$DRY_RUN" != "true" ]]; then if [[ "$DRY_RUN" != "true" ]]; then
rm -f "$ds_file" 2> /dev/null || true rm -f "$ds_file" 2> /dev/null || true
fi fi
if [[ $file_count -ge $MOLE_MAX_DS_STORE_FILES ]]; then
# Stop after 500 files to avoid hanging
if [[ $file_count -ge 500 ]]; then
break break
fi fi
done < <("${find_cmd[@]}" 2> /dev/null || true) done < <("${find_cmd[@]}" 2> /dev/null || true)
if [[ "$spinner_active" == "true" ]]; then if [[ "$spinner_active" == "true" ]]; then
stop_inline_spinner stop_section_spinner
echo -ne "\r\033[K"
fi fi
if [[ $file_count -gt 0 ]]; then if [[ $file_count -gt 0 ]]; then
local size_human local size_human
size_human=$(bytes_to_human "$total_bytes") size_human=$(bytes_to_human "$total_bytes")
if [[ "$DRY_RUN" == "true" ]]; then if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${NC} $label ${YELLOW}($file_count files, $size_human dry)${NC}" echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label ${YELLOW}($file_count files, $size_human dry)${NC}"
else else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label ${GREEN}($file_count files, $size_human)${NC}" echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label ${GREEN}($file_count files, $size_human)${NC}"
fi fi
local size_kb=$(((total_bytes + 1023) / 1024)) local size_kb=$(((total_bytes + 1023) / 1024))
((files_cleaned += file_count)) ((files_cleaned += file_count))
((total_size_cleaned += size_kb)) ((total_size_cleaned += size_kb))
@@ -75,188 +57,132 @@ clean_ds_store_tree() {
note_activity note_activity
fi fi
} }
# Orphaned app data (60+ days inactive). Env: ORPHAN_AGE_THRESHOLD, DRY_RUN
# Clean data for uninstalled apps (caches/logs/states older than 60 days)
# Protects system apps, major vendors, scans /Applications+running processes
# Max 100 items/pattern, 2s du timeout. Env: ORPHAN_AGE_THRESHOLD, DRY_RUN
# Scan system for installed application bundle IDs
# Usage: scan_installed_apps "output_file" # Usage: scan_installed_apps "output_file"
scan_installed_apps() { scan_installed_apps() {
local installed_bundles="$1" local installed_bundles="$1"
# Cache installed app scan briefly to speed repeated runs.
# Scan all Applications directories local cache_file="$HOME/.cache/mole/installed_apps_cache"
local cache_age_seconds=300 # 5 minutes
if [[ -f "$cache_file" ]]; then
local cache_mtime=$(get_file_mtime "$cache_file")
local current_time=$(date +%s)
local age=$((current_time - cache_mtime))
if [[ $age -lt $cache_age_seconds ]]; then
debug_log "Using cached app list (age: ${age}s)"
if [[ -r "$cache_file" ]] && [[ -s "$cache_file" ]]; then
if cat "$cache_file" > "$installed_bundles" 2> /dev/null; then
return 0
else
debug_log "Warning: Failed to read cache, rebuilding"
fi
else
debug_log "Warning: Cache file empty or unreadable, rebuilding"
fi
fi
fi
debug_log "Scanning installed applications (cache expired or missing)"
local -a app_dirs=( local -a app_dirs=(
"/Applications" "/Applications"
"/System/Applications" "/System/Applications"
"$HOME/Applications" "$HOME/Applications"
) )
# Temp dir avoids write contention across parallel scans.
# Create a temp dir for parallel results to avoid write contention
local scan_tmp_dir=$(create_temp_dir) local scan_tmp_dir=$(create_temp_dir)
# Start progress indicator with real-time count
local progress_count_file="$scan_tmp_dir/progress_count"
echo "0" > "$progress_count_file"
# Background spinner that shows live progress
(
trap 'exit 0' TERM INT EXIT
local spinner_chars="|/-\\"
local i=0
while true; do
local count=$(cat "$progress_count_file" 2> /dev/null || echo "0")
local c="${spinner_chars:$((i % 4)):1}"
echo -ne "\r\033[K $c Scanning installed apps... $count found" >&2
((i++))
sleep 0.1
done
) &
local spinner_pid=$!
# Parallel scan for applications
local pids=() local pids=()
local dir_idx=0 local dir_idx=0
for app_dir in "${app_dirs[@]}"; do for app_dir in "${app_dirs[@]}"; do
[[ -d "$app_dir" ]] || continue [[ -d "$app_dir" ]] || continue
( (
# Quickly find all .app bundles first
local -a app_paths=() local -a app_paths=()
while IFS= read -r app_path; do while IFS= read -r app_path; do
[[ -n "$app_path" ]] && app_paths+=("$app_path") [[ -n "$app_path" ]] && app_paths+=("$app_path")
done < <(find "$app_dir" -name '*.app' -maxdepth 3 -type d 2> /dev/null) done < <(find "$app_dir" -name '*.app' -maxdepth 3 -type d 2> /dev/null)
# Read bundle IDs with PlistBuddy
local count=0 local count=0
for app_path in "${app_paths[@]:-}"; do for app_path in "${app_paths[@]:-}"; do
local plist_path="$app_path/Contents/Info.plist" local plist_path="$app_path/Contents/Info.plist"
[[ ! -f "$plist_path" ]] && continue [[ ! -f "$plist_path" ]] && continue
local bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$plist_path" 2> /dev/null || echo "") local bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$plist_path" 2> /dev/null || echo "")
if [[ -n "$bundle_id" ]]; then if [[ -n "$bundle_id" ]]; then
echo "$bundle_id" echo "$bundle_id"
((count++)) ((count++))
# Batch update progress every 10 apps to reduce I/O
if [[ $((count % 10)) -eq 0 ]]; then
local current=$(cat "$progress_count_file" 2> /dev/null || echo "0")
echo "$((current + 10))" > "$progress_count_file"
fi
fi fi
done done
# Final progress update
if [[ $((count % 10)) -ne 0 ]]; then
local current=$(cat "$progress_count_file" 2> /dev/null || echo "0")
echo "$((current + count % 10))" > "$progress_count_file"
fi
) > "$scan_tmp_dir/apps_${dir_idx}.txt" & ) > "$scan_tmp_dir/apps_${dir_idx}.txt" &
pids+=($!) pids+=($!)
((dir_idx++)) ((dir_idx++))
done done
# Collect running apps and LaunchAgents to avoid false orphan cleanup.
# Get running applications and LaunchAgents in parallel
( (
local running_apps=$(run_with_timeout 5 osascript -e 'tell application "System Events" to get bundle identifier of every application process' 2> /dev/null || echo "") local running_apps=$(run_with_timeout 5 osascript -e 'tell application "System Events" to get bundle identifier of every application process' 2> /dev/null || echo "")
echo "$running_apps" | tr ',' '\n' | sed -e 's/^ *//;s/ *$//' -e '/^$/d' > "$scan_tmp_dir/running.txt" echo "$running_apps" | tr ',' '\n' | sed -e 's/^ *//;s/ *$//' -e '/^$/d' > "$scan_tmp_dir/running.txt"
) & ) &
pids+=($!) pids+=($!)
( (
run_with_timeout 5 find ~/Library/LaunchAgents /Library/LaunchAgents \ run_with_timeout 5 find ~/Library/LaunchAgents /Library/LaunchAgents \
-name "*.plist" -type f 2> /dev/null | -name "*.plist" -type f 2> /dev/null |
xargs -I {} basename {} .plist > "$scan_tmp_dir/agents.txt" 2> /dev/null || true xargs -I {} basename {} .plist > "$scan_tmp_dir/agents.txt" 2> /dev/null || true
) & ) &
pids+=($!) pids+=($!)
debug_log "Waiting for ${#pids[@]} background processes: ${pids[*]}"
# Wait for all background scans to complete
for pid in "${pids[@]}"; do for pid in "${pids[@]}"; do
wait "$pid" 2> /dev/null || true wait "$pid" 2> /dev/null || true
done done
debug_log "All background processes completed"
# Stop the spinner
kill -TERM "$spinner_pid" 2> /dev/null || true
wait "$spinner_pid" 2> /dev/null || true
echo -ne "\r\033[K" >&2
# Merge all results
cat "$scan_tmp_dir"/*.txt >> "$installed_bundles" 2> /dev/null || true cat "$scan_tmp_dir"/*.txt >> "$installed_bundles" 2> /dev/null || true
safe_remove "$scan_tmp_dir" true safe_remove "$scan_tmp_dir" true
# Deduplicate
sort -u "$installed_bundles" -o "$installed_bundles" sort -u "$installed_bundles" -o "$installed_bundles"
ensure_user_dir "$(dirname "$cache_file")"
cp "$installed_bundles" "$cache_file" 2> /dev/null || true
local app_count=$(wc -l < "$installed_bundles" 2> /dev/null | tr -d ' ') local app_count=$(wc -l < "$installed_bundles" 2> /dev/null | tr -d ' ')
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Found $app_count active/installed apps" debug_log "Scanned $app_count unique applications"
} }
# Check if bundle is orphaned
# Usage: is_bundle_orphaned "bundle_id" "directory_path" "installed_bundles_file" # Usage: is_bundle_orphaned "bundle_id" "directory_path" "installed_bundles_file"
is_bundle_orphaned() { is_bundle_orphaned() {
local bundle_id="$1" local bundle_id="$1"
local directory_path="$2" local directory_path="$2"
local installed_bundles="$3" local installed_bundles="$3"
# Skip system-critical and protected apps
if should_protect_data "$bundle_id"; then if should_protect_data "$bundle_id"; then
return 1 return 1
fi fi
# Check if app exists in our scan
if grep -Fxq "$bundle_id" "$installed_bundles" 2> /dev/null; then if grep -Fxq "$bundle_id" "$installed_bundles" 2> /dev/null; then
return 1 return 1
fi fi
# Check against centralized protected patterns (app_protection.sh)
if should_protect_data "$bundle_id"; then if should_protect_data "$bundle_id"; then
return 1 return 1
fi fi
# Extra check for specific system bundles not covered by patterns
case "$bundle_id" in case "$bundle_id" in
loginwindow | dock | systempreferences | finder | safari) loginwindow | dock | systempreferences | systemsettings | settings | controlcenter | finder | safari)
return 1 return 1
;; ;;
esac esac
# Check file age - only clean if 60+ days inactive
# Use existing logic
if [[ -e "$directory_path" ]]; then if [[ -e "$directory_path" ]]; then
local last_modified_epoch=$(get_file_mtime "$directory_path") local last_modified_epoch=$(get_file_mtime "$directory_path")
local current_epoch=$(date +%s) local current_epoch=$(date +%s)
local days_since_modified=$(((current_epoch - last_modified_epoch) / 86400)) local days_since_modified=$(((current_epoch - last_modified_epoch) / 86400))
if [[ $days_since_modified -lt ${ORPHAN_AGE_THRESHOLD:-60} ]]; then if [[ $days_since_modified -lt ${ORPHAN_AGE_THRESHOLD:-60} ]]; then
return 1 return 1
fi fi
fi fi
return 0 return 0
} }
# Orphaned app data sweep.
# Clean data for uninstalled apps (caches/logs/states older than 60 days)
# Protects system apps, major vendors, scans /Applications+running processes
# Max 100 items/pattern, 2s du timeout. Env: ORPHAN_AGE_THRESHOLD, DRY_RUN
clean_orphaned_app_data() { clean_orphaned_app_data() {
# Quick permission check - if we can't access Library folders, skip
if ! ls "$HOME/Library/Caches" > /dev/null 2>&1; then if ! ls "$HOME/Library/Caches" > /dev/null 2>&1; then
stop_section_spinner
echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped: No permission to access Library folders" echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped: No permission to access Library folders"
return 0 return 0
fi fi
start_section_spinner "Scanning installed apps..."
# Build list of installed/active apps
local installed_bundles=$(create_temp_file) local installed_bundles=$(create_temp_file)
scan_installed_apps "$installed_bundles" scan_installed_apps "$installed_bundles"
stop_section_spinner
# Track statistics local app_count=$(wc -l < "$installed_bundles" 2> /dev/null | tr -d ' ')
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Found $app_count active/installed apps"
local orphaned_count=0 local orphaned_count=0
local total_orphaned_kb=0 local total_orphaned_kb=0
start_section_spinner "Scanning orphaned app resources..."
# Unified orphaned resource scanner (caches, logs, states, webkit, HTTP, cookies) # CRITICAL: NEVER add LaunchAgents or LaunchDaemons (breaks login items/startup apps).
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning orphaned app resources..."
# Define resource types to scan
# CRITICAL: NEVER add LaunchAgents or LaunchDaemons (breaks login items/startup apps)
local -a resource_types=( local -a resource_types=(
"$HOME/Library/Caches|Caches|com.*:org.*:net.*:io.*" "$HOME/Library/Caches|Caches|com.*:org.*:net.*:io.*"
"$HOME/Library/Logs|Logs|com.*:org.*:net.*:io.*" "$HOME/Library/Logs|Logs|com.*:org.*:net.*:io.*"
@@ -265,52 +191,32 @@ clean_orphaned_app_data() {
"$HOME/Library/HTTPStorages|HTTP|com.*:org.*:net.*:io.*" "$HOME/Library/HTTPStorages|HTTP|com.*:org.*:net.*:io.*"
"$HOME/Library/Cookies|Cookies|*.binarycookies" "$HOME/Library/Cookies|Cookies|*.binarycookies"
) )
orphaned_count=0 orphaned_count=0
for resource_type in "${resource_types[@]}"; do for resource_type in "${resource_types[@]}"; do
IFS='|' read -r base_path label patterns <<< "$resource_type" IFS='|' read -r base_path label patterns <<< "$resource_type"
# Check both existence and permission to avoid hanging
if [[ ! -d "$base_path" ]]; then if [[ ! -d "$base_path" ]]; then
continue continue
fi fi
# Quick permission check - if we can't ls the directory, skip it
if ! ls "$base_path" > /dev/null 2>&1; then if ! ls "$base_path" > /dev/null 2>&1; then
continue continue
fi fi
# Build file pattern array
local -a file_patterns=() local -a file_patterns=()
IFS=':' read -ra pattern_arr <<< "$patterns" IFS=':' read -ra pattern_arr <<< "$patterns"
for pat in "${pattern_arr[@]}"; do for pat in "${pattern_arr[@]}"; do
file_patterns+=("$base_path/$pat") file_patterns+=("$base_path/$pat")
done done
# Scan and clean orphaned items
for item_path in "${file_patterns[@]}"; do for item_path in "${file_patterns[@]}"; do
# Use shell glob (no ls needed)
# Limit iterations to prevent hanging on directories with too many files
local iteration_count=0 local iteration_count=0
local max_iterations=100
for match in $item_path; do for match in $item_path; do
[[ -e "$match" ]] || continue [[ -e "$match" ]] || continue
# Safety: limit iterations to prevent infinite loops on massive directories
((iteration_count++)) ((iteration_count++))
if [[ $iteration_count -gt $max_iterations ]]; then if [[ $iteration_count -gt $MOLE_MAX_ORPHAN_ITERATIONS ]]; then
break break
fi fi
# Extract bundle ID from filename
local bundle_id=$(basename "$match") local bundle_id=$(basename "$match")
bundle_id="${bundle_id%.savedState}" bundle_id="${bundle_id%.savedState}"
bundle_id="${bundle_id%.binarycookies}" bundle_id="${bundle_id%.binarycookies}"
if is_bundle_orphaned "$bundle_id" "$match" "$installed_bundles"; then if is_bundle_orphaned "$bundle_id" "$match" "$installed_bundles"; then
# Use timeout to prevent du from hanging on network mounts or problematic paths
local size_kb local size_kb
size_kb=$(get_path_size_kb "$match") size_kb=$(get_path_size_kb "$match")
if [[ -z "$size_kb" || "$size_kb" == "0" ]]; then if [[ -z "$size_kb" || "$size_kb" == "0" ]]; then
@@ -323,14 +229,11 @@ clean_orphaned_app_data() {
done done
done done
done done
stop_section_spinner
stop_inline_spinner
if [[ $orphaned_count -gt 0 ]]; then if [[ $orphaned_count -gt 0 ]]; then
local orphaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}') local orphaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}')
echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count items (~${orphaned_mb}MB)" echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count items (~${orphaned_mb}MB)"
note_activity note_activity
fi fi
rm -f "$installed_bundles" rm -f "$installed_bundles"
} }

View File

@@ -1,22 +1,17 @@
#!/bin/bash #!/bin/bash
# Clean Homebrew caches and remove orphaned dependencies # Clean Homebrew caches and remove orphaned dependencies
# Skips if run within 2 days, runs cleanup/autoremove in parallel with 120s timeout
# Env: MO_BREW_TIMEOUT, DRY_RUN # Env: MO_BREW_TIMEOUT, DRY_RUN
# Skips if run within 7 days, runs cleanup/autoremove in parallel with 120s timeout
clean_homebrew() { clean_homebrew() {
command -v brew > /dev/null 2>&1 || return 0 command -v brew > /dev/null 2>&1 || return 0
# Dry run mode - just indicate what would happen
if [[ "${DRY_RUN:-false}" == "true" ]]; then if [[ "${DRY_RUN:-false}" == "true" ]]; then
echo -e " ${YELLOW}${NC} Homebrew (would cleanup and autoremove)" echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Homebrew · would cleanup and autoremove"
return 0 return 0
fi fi
# Skip if cleaned recently to avoid repeated heavy operations.
# Smart caching: check if brew cleanup was run recently (within 2 days)
local brew_cache_file="${HOME}/.cache/mole/brew_last_cleanup" local brew_cache_file="${HOME}/.cache/mole/brew_last_cleanup"
local cache_valid_days=2 local cache_valid_days=7
local should_skip=false local should_skip=false
if [[ -f "$brew_cache_file" ]]; then if [[ -f "$brew_cache_file" ]]; then
local last_cleanup local last_cleanup
last_cleanup=$(cat "$brew_cache_file" 2> /dev/null || echo "0") last_cleanup=$(cat "$brew_cache_file" 2> /dev/null || echo "0")
@@ -24,71 +19,80 @@ clean_homebrew() {
current_time=$(date +%s) current_time=$(date +%s)
local time_diff=$((current_time - last_cleanup)) local time_diff=$((current_time - last_cleanup))
local days_diff=$((time_diff / 86400)) local days_diff=$((time_diff / 86400))
if [[ $days_diff -lt $cache_valid_days ]]; then if [[ $days_diff -lt $cache_valid_days ]]; then
should_skip=true should_skip=true
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew (cleaned ${days_diff}d ago, skipped)" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew · cleaned ${days_diff}d ago, skipped"
fi fi
fi fi
[[ "$should_skip" == "true" ]] && return 0 [[ "$should_skip" == "true" ]] && return 0
# Skip cleanup if cache is small; still run autoremove.
if [[ -t 1 ]]; then local skip_cleanup=false
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Homebrew cleanup and autoremove..." local brew_cache_size=0
if [[ -d ~/Library/Caches/Homebrew ]]; then
brew_cache_size=$(run_with_timeout 3 du -sk ~/Library/Caches/Homebrew 2> /dev/null | awk '{print $1}')
local du_exit=$?
if [[ $du_exit -eq 0 && -n "$brew_cache_size" && "$brew_cache_size" -lt 51200 ]]; then
skip_cleanup=true
fi
fi fi
# Spinner reflects whether cleanup is skipped.
if [[ -t 1 ]]; then
if [[ "$skip_cleanup" == "true" ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Homebrew autoremove (cleanup skipped)..."
else
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Homebrew cleanup and autoremove..."
fi
fi
# Run cleanup/autoremove in parallel with a timeout guard.
local timeout_seconds=${MO_BREW_TIMEOUT:-120} local timeout_seconds=${MO_BREW_TIMEOUT:-120}
# Run brew cleanup and autoremove in parallel for performance
local brew_tmp_file autoremove_tmp_file local brew_tmp_file autoremove_tmp_file
brew_tmp_file=$(create_temp_file) local brew_pid autoremove_pid
if [[ "$skip_cleanup" == "false" ]]; then
brew_tmp_file=$(create_temp_file)
(brew cleanup > "$brew_tmp_file" 2>&1) &
brew_pid=$!
fi
autoremove_tmp_file=$(create_temp_file) autoremove_tmp_file=$(create_temp_file)
(brew cleanup > "$brew_tmp_file" 2>&1) &
local brew_pid=$!
(brew autoremove > "$autoremove_tmp_file" 2>&1) & (brew autoremove > "$autoremove_tmp_file" 2>&1) &
local autoremove_pid=$! autoremove_pid=$!
local elapsed=0 local elapsed=0
local brew_done=false local brew_done=false
local autoremove_done=false local autoremove_done=false
[[ "$skip_cleanup" == "true" ]] && brew_done=true
# Wait for both to complete or timeout
while [[ "$brew_done" == "false" ]] || [[ "$autoremove_done" == "false" ]]; do while [[ "$brew_done" == "false" ]] || [[ "$autoremove_done" == "false" ]]; do
if [[ $elapsed -ge $timeout_seconds ]]; then if [[ $elapsed -ge $timeout_seconds ]]; then
kill -TERM $brew_pid $autoremove_pid 2> /dev/null || true [[ -n "$brew_pid" ]] && kill -TERM $brew_pid 2> /dev/null || true
kill -TERM $autoremove_pid 2> /dev/null || true
break break
fi fi
[[ -n "$brew_pid" ]] && { kill -0 $brew_pid 2> /dev/null || brew_done=true; }
kill -0 $brew_pid 2> /dev/null || brew_done=true
kill -0 $autoremove_pid 2> /dev/null || autoremove_done=true kill -0 $autoremove_pid 2> /dev/null || autoremove_done=true
sleep 1 sleep 1
((elapsed++)) ((elapsed++))
done done
# Wait for processes to finish
local brew_success=false local brew_success=false
if wait $brew_pid 2> /dev/null; then if [[ "$skip_cleanup" == "false" && -n "$brew_pid" ]]; then
brew_success=true if wait $brew_pid 2> /dev/null; then
brew_success=true
fi
fi fi
local autoremove_success=false local autoremove_success=false
if wait $autoremove_pid 2> /dev/null; then if wait $autoremove_pid 2> /dev/null; then
autoremove_success=true autoremove_success=true
fi fi
if [[ -t 1 ]]; then stop_inline_spinner; fi if [[ -t 1 ]]; then stop_inline_spinner; fi
# Process cleanup output and extract metrics # Process cleanup output and extract metrics
if [[ "$brew_success" == "true" && -f "$brew_tmp_file" ]]; then # Summarize cleanup results.
if [[ "$skip_cleanup" == "true" ]]; then
# Cleanup was skipped due to small cache size
local size_mb=$((brew_cache_size / 1024))
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup · cache ${size_mb}MB, skipped"
elif [[ "$brew_success" == "true" && -f "$brew_tmp_file" ]]; then
local brew_output local brew_output
brew_output=$(cat "$brew_tmp_file" 2> /dev/null || echo "") brew_output=$(cat "$brew_tmp_file" 2> /dev/null || echo "")
local removed_count freed_space local removed_count freed_space
removed_count=$(printf '%s\n' "$brew_output" | grep -c "Removing:" 2> /dev/null || true) removed_count=$(printf '%s\n' "$brew_output" | grep -c "Removing:" 2> /dev/null || true)
freed_space=$(printf '%s\n' "$brew_output" | grep -o "[0-9.]*[KMGT]B freed" 2> /dev/null | tail -1 || true) freed_space=$(printf '%s\n' "$brew_output" | grep -o "[0-9.]*[KMGT]B freed" 2> /dev/null | tail -1 || true)
if [[ $removed_count -gt 0 ]] || [[ -n "$freed_space" ]]; then if [[ $removed_count -gt 0 ]] || [[ -n "$freed_space" ]]; then
if [[ -n "$freed_space" ]]; then if [[ -n "$freed_space" ]]; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup ${GREEN}($freed_space)${NC}" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew cleanup ${GREEN}($freed_space)${NC}"
@@ -97,26 +101,26 @@ clean_homebrew() {
fi fi
fi fi
elif [[ $elapsed -ge $timeout_seconds ]]; then elif [[ $elapsed -ge $timeout_seconds ]]; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Homebrew cleanup timed out (run ${GRAY}brew cleanup${NC} manually)" echo -e " ${YELLOW}${ICON_WARNING}${NC} Homebrew cleanup timed out · run ${GRAY}brew cleanup${NC} manually"
fi fi
# Process autoremove output - only show if packages were removed # Process autoremove output - only show if packages were removed
# Only surface autoremove output when packages were removed.
if [[ "$autoremove_success" == "true" && -f "$autoremove_tmp_file" ]]; then if [[ "$autoremove_success" == "true" && -f "$autoremove_tmp_file" ]]; then
local autoremove_output local autoremove_output
autoremove_output=$(cat "$autoremove_tmp_file" 2> /dev/null || echo "") autoremove_output=$(cat "$autoremove_tmp_file" 2> /dev/null || echo "")
local removed_packages local removed_packages
removed_packages=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" 2> /dev/null || true) removed_packages=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" 2> /dev/null || true)
if [[ $removed_packages -gt 0 ]]; then if [[ $removed_packages -gt 0 ]]; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed orphaned dependencies (${removed_packages} packages)" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed orphaned dependencies (${removed_packages} packages)"
fi fi
elif [[ $elapsed -ge $timeout_seconds ]]; then elif [[ $elapsed -ge $timeout_seconds ]]; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Autoremove timed out (run ${GRAY}brew autoremove${NC} manually)" echo -e " ${YELLOW}${ICON_WARNING}${NC} Autoremove timed out · run ${GRAY}brew autoremove${NC} manually"
fi fi
# Update cache timestamp on successful completion or when cleanup was intelligently skipped
# Update cache timestamp on successful completion # This prevents repeated cache size checks within the 7-day window
if [[ "$brew_success" == "true" || "$autoremove_success" == "true" ]]; then # Update cache timestamp when any work succeeded or was intentionally skipped.
mkdir -p "$(dirname "$brew_cache_file")" if [[ "$skip_cleanup" == "true" ]] || [[ "$brew_success" == "true" ]] || [[ "$autoremove_success" == "true" ]]; then
ensure_user_file "$brew_cache_file"
date +%s > "$brew_cache_file" date +%s > "$brew_cache_file"
fi fi
} }

View File

@@ -1,20 +1,11 @@
#!/bin/bash #!/bin/bash
# Cache Cleanup Module # Cache Cleanup Module
set -euo pipefail set -euo pipefail
# Preflight TCC prompts once to avoid mid-run interruptions.
# Trigger all TCC permission dialogs upfront to avoid random interruptions
# Only runs once (uses ~/.cache/mole/permissions_granted flag)
check_tcc_permissions() { check_tcc_permissions() {
# Only check in interactive mode
[[ -t 1 ]] || return 0 [[ -t 1 ]] || return 0
local permission_flag="$HOME/.cache/mole/permissions_granted" local permission_flag="$HOME/.cache/mole/permissions_granted"
# Skip if permissions were already granted
[[ -f "$permission_flag" ]] && return 0 [[ -f "$permission_flag" ]] && return 0
# Key protected directories that require TCC approval
local -a tcc_dirs=( local -a tcc_dirs=(
"$HOME/Library/Caches" "$HOME/Library/Caches"
"$HOME/Library/Logs" "$HOME/Library/Logs"
@@ -22,14 +13,11 @@ check_tcc_permissions() {
"$HOME/Library/Containers" "$HOME/Library/Containers"
"$HOME/.cache" "$HOME/.cache"
) )
# Quick permission probe (avoid deep scans).
# Quick permission test - if first directory is accessible, likely others are too
# Use simple ls test instead of find to avoid triggering permission dialogs prematurely
local needs_permission_check=false local needs_permission_check=false
if ! ls "$HOME/Library/Caches" > /dev/null 2>&1; then if ! ls "$HOME/Library/Caches" > /dev/null 2>&1; then
needs_permission_check=true needs_permission_check=true
fi fi
if [[ "$needs_permission_check" == "true" ]]; then if [[ "$needs_permission_check" == "true" ]]; then
echo "" echo ""
echo -e "${BLUE}First-time setup${NC}" echo -e "${BLUE}First-time setup${NC}"
@@ -38,46 +26,31 @@ check_tcc_permissions() {
echo "" echo ""
echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to continue: " echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to continue: "
read -r read -r
MOLE_SPINNER_PREFIX="" start_inline_spinner "Requesting permissions..." MOLE_SPINNER_PREFIX="" start_inline_spinner "Requesting permissions..."
# Touch each directory to trigger prompts without deep scanning.
# Trigger all TCC prompts upfront by accessing each directory
# Using find -maxdepth 1 ensures we touch the directory without deep scanning
for dir in "${tcc_dirs[@]}"; do for dir in "${tcc_dirs[@]}"; do
[[ -d "$dir" ]] && command find "$dir" -maxdepth 1 -type d > /dev/null 2>&1 [[ -d "$dir" ]] && command find "$dir" -maxdepth 1 -type d > /dev/null 2>&1
done done
stop_inline_spinner stop_inline_spinner
echo "" echo ""
fi fi
# Mark as granted to avoid repeat prompts.
# Mark permissions as granted (won't prompt again) ensure_user_file "$permission_flag"
mkdir -p "$(dirname "$permission_flag")" 2> /dev/null || true return 0
touch "$permission_flag" 2> /dev/null || true
} }
# Clean browser Service Worker cache, protecting web editing tools (capcut, photopea, pixlr)
# Args: $1=browser_name, $2=cache_path # Args: $1=browser_name, $2=cache_path
# Clean Service Worker cache while protecting critical web editors.
clean_service_worker_cache() { clean_service_worker_cache() {
local browser_name="$1" local browser_name="$1"
local cache_path="$2" local cache_path="$2"
[[ ! -d "$cache_path" ]] && return 0 [[ ! -d "$cache_path" ]] && return 0
local cleaned_size=0 local cleaned_size=0
local protected_count=0 local protected_count=0
# Find all cache directories and calculate sizes with timeout protection
while IFS= read -r cache_dir; do while IFS= read -r cache_dir; do
[[ ! -d "$cache_dir" ]] && continue [[ ! -d "$cache_dir" ]] && continue
# Extract a best-effort domain name from cache folder.
# Extract domain from path using regex
# Pattern matches: letters/numbers, hyphens, then dot, then TLD
# Example: "abc123_https_example.com_0" → "example.com"
local domain=$(basename "$cache_dir" | grep -oE '[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}' | head -1 || echo "") local domain=$(basename "$cache_dir" | grep -oE '[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}' | head -1 || echo "")
local size=$(run_with_timeout 5 get_path_size_kb "$cache_dir") local size=$(run_with_timeout 5 get_path_size_kb "$cache_dir")
# Check if domain is protected
local is_protected=false local is_protected=false
for protected_domain in "${PROTECTED_SW_DOMAINS[@]}"; do for protected_domain in "${PROTECTED_SW_DOMAINS[@]}"; do
if [[ "$domain" == *"$protected_domain"* ]]; then if [[ "$domain" == *"$protected_domain"* ]]; then
@@ -86,8 +59,6 @@ clean_service_worker_cache() {
break break
fi fi
done done
# Clean if not protected
if [[ "$is_protected" == "false" ]]; then if [[ "$is_protected" == "false" ]]; then
if [[ "$DRY_RUN" != "true" ]]; then if [[ "$DRY_RUN" != "true" ]]; then
safe_remove "$cache_dir" true || true safe_remove "$cache_dir" true || true
@@ -95,15 +66,12 @@ clean_service_worker_cache() {
cleaned_size=$((cleaned_size + size)) cleaned_size=$((cleaned_size + size))
fi fi
done < <(run_with_timeout 10 sh -c "find '$cache_path' -type d -depth 2 2> /dev/null || true") done < <(run_with_timeout 10 sh -c "find '$cache_path' -type d -depth 2 2> /dev/null || true")
if [[ $cleaned_size -gt 0 ]]; then if [[ $cleaned_size -gt 0 ]]; then
# Temporarily stop spinner for clean output
local spinner_was_running=false local spinner_was_running=false
if [[ -t 1 && -n "${INLINE_SPINNER_PID:-}" ]]; then if [[ -t 1 && -n "${INLINE_SPINNER_PID:-}" ]]; then
stop_inline_spinner stop_inline_spinner
spinner_was_running=true spinner_was_running=true
fi fi
local cleaned_mb=$((cleaned_size / 1024)) local cleaned_mb=$((cleaned_size / 1024))
if [[ "$DRY_RUN" != "true" ]]; then if [[ "$DRY_RUN" != "true" ]]; then
if [[ $protected_count -gt 0 ]]; then if [[ $protected_count -gt 0 ]]; then
@@ -112,32 +80,84 @@ clean_service_worker_cache() {
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker (${cleaned_mb}MB)" echo -e " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker (${cleaned_mb}MB)"
fi fi
else else
echo -e " ${YELLOW}${NC} $browser_name Service Worker (would clean ${cleaned_mb}MB, ${protected_count} protected)" echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $browser_name Service Worker (would clean ${cleaned_mb}MB, ${protected_count} protected)"
fi fi
note_activity note_activity
# Restart spinner if it was running
if [[ "$spinner_was_running" == "true" ]]; then if [[ "$spinner_was_running" == "true" ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning browser Service Worker caches..." MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning browser Service Worker caches..."
fi fi
fi fi
} }
# Next.js/Python project caches with tight scan bounds and timeouts.
# Clean Next.js (.next/cache) and Python (__pycache__) build caches
# Uses maxdepth 3, excludes Library/.Trash/node_modules, 10s timeout per scan
clean_project_caches() { clean_project_caches() {
stop_inline_spinner 2> /dev/null || true
# Fast pre-check before scanning the whole home dir.
local has_dev_projects=false
local -a common_dev_dirs=(
"$HOME/Code"
"$HOME/Projects"
"$HOME/workspace"
"$HOME/github"
"$HOME/dev"
"$HOME/work"
"$HOME/src"
"$HOME/repos"
"$HOME/Development"
"$HOME/www"
"$HOME/golang"
"$HOME/go"
"$HOME/rust"
"$HOME/python"
"$HOME/ruby"
"$HOME/java"
"$HOME/dotnet"
"$HOME/node"
)
for dir in "${common_dev_dirs[@]}"; do
if [[ -d "$dir" ]]; then
has_dev_projects=true
break
fi
done
# Fallback: look for project markers near $HOME.
if [[ "$has_dev_projects" == "false" ]]; then
local -a project_markers=(
"node_modules"
".git"
"target"
"go.mod"
"Cargo.toml"
"package.json"
"pom.xml"
"build.gradle"
)
local spinner_active=false
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" "
start_inline_spinner "Detecting dev projects..."
spinner_active=true
fi
for marker in "${project_markers[@]}"; do
if run_with_timeout 3 sh -c "find '$HOME' -maxdepth 2 -name '$marker' -not -path '*/Library/*' -not -path '*/.Trash/*' 2>/dev/null | head -1" | grep -q .; then
has_dev_projects=true
break
fi
done
if [[ "$spinner_active" == "true" ]]; then
stop_inline_spinner 2> /dev/null || true
fi
[[ "$has_dev_projects" == "false" ]] && return 0
fi
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" " MOLE_SPINNER_PREFIX=" "
start_inline_spinner "Searching project caches..." start_inline_spinner "Searching project caches..."
fi fi
local nextjs_tmp_file local nextjs_tmp_file
nextjs_tmp_file=$(create_temp_file) nextjs_tmp_file=$(create_temp_file)
local pycache_tmp_file local pycache_tmp_file
pycache_tmp_file=$(create_temp_file) pycache_tmp_file=$(create_temp_file)
local find_timeout=10 local find_timeout=10
# Parallel scans (Next.js and __pycache__).
# 1. Start Next.js search
( (
command find "$HOME" -P -mount -type d -name ".next" -maxdepth 3 \ command find "$HOME" -P -mount -type d -name ".next" -maxdepth 3 \
-not -path "*/Library/*" \ -not -path "*/Library/*" \
@@ -147,8 +167,6 @@ clean_project_caches() {
2> /dev/null || true 2> /dev/null || true
) > "$nextjs_tmp_file" 2>&1 & ) > "$nextjs_tmp_file" 2>&1 &
local next_pid=$! local next_pid=$!
# 2. Start Python search
( (
command find "$HOME" -P -mount -type d -name "__pycache__" -maxdepth 3 \ command find "$HOME" -P -mount -type d -name "__pycache__" -maxdepth 3 \
-not -path "*/Library/*" \ -not -path "*/Library/*" \
@@ -158,90 +176,42 @@ clean_project_caches() {
2> /dev/null || true 2> /dev/null || true
) > "$pycache_tmp_file" 2>&1 & ) > "$pycache_tmp_file" 2>&1 &
local py_pid=$! local py_pid=$!
# 3. Wait for both with timeout
local elapsed=0 local elapsed=0
while [[ $elapsed -lt $find_timeout ]]; do local check_interval=0.2 # Check every 200ms instead of 1s for smoother experience
while [[ $(echo "$elapsed < $find_timeout" | awk '{print ($1 < $2)}') -eq 1 ]]; do
if ! kill -0 $next_pid 2> /dev/null && ! kill -0 $py_pid 2> /dev/null; then if ! kill -0 $next_pid 2> /dev/null && ! kill -0 $py_pid 2> /dev/null; then
break break
fi fi
sleep 1 sleep $check_interval
((elapsed++)) elapsed=$(echo "$elapsed + $check_interval" | awk '{print $1 + $2}')
done done
# Kill stuck scans after timeout.
# 4. Clean up any stuck processes
for pid in $next_pid $py_pid; do for pid in $next_pid $py_pid; do
if kill -0 "$pid" 2> /dev/null; then if kill -0 "$pid" 2> /dev/null; then
kill -TERM "$pid" 2> /dev/null || true kill -TERM "$pid" 2> /dev/null || true
local grace_period=0
while [[ $grace_period -lt 20 ]]; do
if ! kill -0 "$pid" 2> /dev/null; then
break
fi
sleep 0.1
((grace_period++))
done
if kill -0 "$pid" 2> /dev/null; then
kill -KILL "$pid" 2> /dev/null || true
fi
wait "$pid" 2> /dev/null || true wait "$pid" 2> /dev/null || true
else else
wait "$pid" 2> /dev/null || true wait "$pid" 2> /dev/null || true
fi fi
done done
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
stop_inline_spinner stop_inline_spinner
fi fi
# 5. Process Next.js results
while IFS= read -r next_dir; do while IFS= read -r next_dir; do
[[ -d "$next_dir/cache" ]] && safe_clean "$next_dir/cache"/* "Next.js build cache" || true [[ -d "$next_dir/cache" ]] && safe_clean "$next_dir/cache"/* "Next.js build cache" || true
done < "$nextjs_tmp_file" done < "$nextjs_tmp_file"
# 6. Process Python results
while IFS= read -r pycache; do while IFS= read -r pycache; do
[[ -d "$pycache" ]] && safe_clean "$pycache"/* "Python bytecode cache" || true [[ -d "$pycache" ]] && safe_clean "$pycache"/* "Python bytecode cache" || true
done < "$pycache_tmp_file" done < "$pycache_tmp_file"
} }
# Clean Spotlight user caches
clean_spotlight_caches() {
local cleaned_size=0
local cleaned_count=0
# CoreSpotlight user cache (can grow very large, safe to delete)
local spotlight_cache="$HOME/Library/Metadata/CoreSpotlight"
if [[ -d "$spotlight_cache" ]]; then
local size_kb=$(get_path_size_kb "$spotlight_cache")
if [[ "$size_kb" -gt 0 ]]; then
if [[ "$DRY_RUN" != "true" ]]; then
safe_remove "$spotlight_cache" true && {
((cleaned_size += size_kb))
((cleaned_count++))
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Spotlight cache ($(bytes_to_human $((size_kb * 1024))))"
note_activity
}
else
((cleaned_size += size_kb))
echo -e " ${YELLOW}${NC} Spotlight cache (would clean $(bytes_to_human $((size_kb * 1024))))"
note_activity
fi
fi
fi
# Spotlight saved application state
local spotlight_state="$HOME/Library/Saved Application State/com.apple.spotlight.Spotlight.savedState"
if [[ -d "$spotlight_state" ]]; then
local size_kb=$(get_path_size_kb "$spotlight_state")
if [[ "$size_kb" -gt 0 ]]; then
if [[ "$DRY_RUN" != "true" ]]; then
safe_remove "$spotlight_state" true && {
((cleaned_size += size_kb))
((cleaned_count++))
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Spotlight state ($(bytes_to_human $((size_kb * 1024))))"
note_activity
}
else
((cleaned_size += size_kb))
echo -e " ${YELLOW}${NC} Spotlight state (would clean $(bytes_to_human $((size_kb * 1024))))"
note_activity
fi
fi
fi
if [[ $cleaned_size -gt 0 ]]; then
((files_cleaned += cleaned_count))
((total_size_cleaned += cleaned_size))
((total_items++))
fi
}

View File

@@ -1,52 +1,53 @@
#!/bin/bash #!/bin/bash
# Developer Tools Cleanup Module # Developer Tools Cleanup Module
set -euo pipefail set -euo pipefail
# Tool cache helper (respects DRY_RUN).
# Helper function to clean tool caches using their built-in commands
# Args: $1 - description, $@ - command to execute
# Env: DRY_RUN
clean_tool_cache() { clean_tool_cache() {
local description="$1" local description="$1"
shift shift
if [[ "$DRY_RUN" != "true" ]]; then if [[ "$DRY_RUN" != "true" ]]; then
if "$@" > /dev/null 2>&1; then if "$@" > /dev/null 2>&1; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $description" echo -e " ${GREEN}${ICON_SUCCESS}${NC} $description"
fi fi
else else
echo -e " ${YELLOW}${NC} $description (would clean)" echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $description · would clean"
fi fi
return 0
} }
# npm/pnpm/yarn/bun caches.
# Clean npm cache (command + directories)
# npm cache clean clears official npm cache, safe_clean handles alternative package managers
# Env: DRY_RUN
clean_dev_npm() { clean_dev_npm() {
if command -v npm > /dev/null 2>&1; then if command -v npm > /dev/null 2>&1; then
# clean_tool_cache now calculates size before cleanup for better statistics
clean_tool_cache "npm cache" npm cache clean --force clean_tool_cache "npm cache" npm cache clean --force
note_activity note_activity
fi fi
# Clean pnpm store cache
# Clean alternative package manager caches local pnpm_default_store=~/Library/pnpm/store
# Check if pnpm is actually usable (not just Corepack shim)
if command -v pnpm > /dev/null 2>&1 && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 pnpm --version > /dev/null 2>&1; then
COREPACK_ENABLE_DOWNLOAD_PROMPT=0 clean_tool_cache "pnpm cache" pnpm store prune
local pnpm_store_path
start_section_spinner "Checking store path..."
pnpm_store_path=$(COREPACK_ENABLE_DOWNLOAD_PROMPT=0 run_with_timeout 2 pnpm store path 2> /dev/null) || pnpm_store_path=""
stop_section_spinner
if [[ -n "$pnpm_store_path" && "$pnpm_store_path" != "$pnpm_default_store" ]]; then
safe_clean "$pnpm_default_store"/* "Orphaned pnpm store"
fi
else
# pnpm not installed or not usable, just clean the default store directory
safe_clean "$pnpm_default_store"/* "pnpm store"
fi
note_activity
safe_clean ~/.tnpm/_cacache/* "tnpm cache directory" safe_clean ~/.tnpm/_cacache/* "tnpm cache directory"
safe_clean ~/.tnpm/_logs/* "tnpm logs" safe_clean ~/.tnpm/_logs/* "tnpm logs"
safe_clean ~/.yarn/cache/* "Yarn cache" safe_clean ~/.yarn/cache/* "Yarn cache"
safe_clean ~/.bun/install/cache/* "Bun cache" safe_clean ~/.bun/install/cache/* "Bun cache"
} }
# Python/pip ecosystem caches.
# Clean Python/pip cache (command + directories)
# pip cache purge clears official pip cache, safe_clean handles other Python tools
# Env: DRY_RUN
clean_dev_python() { clean_dev_python() {
if command -v pip3 > /dev/null 2>&1; then if command -v pip3 > /dev/null 2>&1; then
# clean_tool_cache now calculates size before cleanup for better statistics
clean_tool_cache "pip cache" bash -c 'pip3 cache purge >/dev/null 2>&1 || true' clean_tool_cache "pip cache" bash -c 'pip3 cache purge >/dev/null 2>&1 || true'
note_activity note_activity
fi fi
# Clean Python ecosystem caches
safe_clean ~/.pyenv/cache/* "pyenv cache" safe_clean ~/.pyenv/cache/* "pyenv cache"
safe_clean ~/.cache/poetry/* "Poetry cache" safe_clean ~/.cache/poetry/* "Poetry cache"
safe_clean ~/.cache/uv/* "uv cache" safe_clean ~/.cache/uv/* "uv cache"
@@ -61,60 +62,53 @@ clean_dev_python() {
safe_clean ~/anaconda3/pkgs/* "Anaconda packages cache" safe_clean ~/anaconda3/pkgs/* "Anaconda packages cache"
safe_clean ~/.cache/wandb/* "Weights & Biases cache" safe_clean ~/.cache/wandb/* "Weights & Biases cache"
} }
# Go build/module caches.
# Clean Go cache (command + directories)
# go clean handles build and module caches comprehensively
# Env: DRY_RUN
clean_dev_go() { clean_dev_go() {
if command -v go > /dev/null 2>&1; then if command -v go > /dev/null 2>&1; then
# clean_tool_cache now calculates size before cleanup for better statistics
clean_tool_cache "Go cache" bash -c 'go clean -modcache >/dev/null 2>&1 || true; go clean -cache >/dev/null 2>&1 || true' clean_tool_cache "Go cache" bash -c 'go clean -modcache >/dev/null 2>&1 || true; go clean -cache >/dev/null 2>&1 || true'
note_activity note_activity
fi fi
} }
# Rust/cargo caches.
# Clean Rust/cargo cache directories
clean_dev_rust() { clean_dev_rust() {
safe_clean ~/.cargo/registry/cache/* "Rust cargo cache" safe_clean ~/.cargo/registry/cache/* "Rust cargo cache"
safe_clean ~/.cargo/git/* "Cargo git cache" safe_clean ~/.cargo/git/* "Cargo git cache"
safe_clean ~/.rustup/downloads/* "Rust downloads cache" safe_clean ~/.rustup/downloads/* "Rust downloads cache"
} }
# Docker caches (guarded by daemon check).
# Clean Docker cache (command + directories)
# Env: DRY_RUN
clean_dev_docker() { clean_dev_docker() {
if command -v docker > /dev/null 2>&1; then if command -v docker > /dev/null 2>&1; then
if [[ "$DRY_RUN" != "true" ]]; then if [[ "$DRY_RUN" != "true" ]]; then
# Check if Docker daemon is running (with timeout to prevent hanging) start_section_spinner "Checking Docker daemon..."
local docker_running=false
if run_with_timeout 3 docker info > /dev/null 2>&1; then if run_with_timeout 3 docker info > /dev/null 2>&1; then
docker_running=true
fi
stop_section_spinner
if [[ "$docker_running" == "true" ]]; then
clean_tool_cache "Docker build cache" docker builder prune -af clean_tool_cache "Docker build cache" docker builder prune -af
else else
note_activity debug_log "Docker daemon not running, skipping Docker cache cleanup"
echo -e " ${GRAY}${ICON_SUCCESS}${NC} Docker build cache (daemon not running)"
fi fi
else else
note_activity note_activity
echo -e " ${YELLOW}${NC} Docker build cache (would clean)" echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Docker build cache · would clean"
fi fi
fi fi
safe_clean ~/.docker/buildx/cache/* "Docker BuildX cache" safe_clean ~/.docker/buildx/cache/* "Docker BuildX cache"
} }
# Nix garbage collection.
# Clean Nix package manager
# Env: DRY_RUN
clean_dev_nix() { clean_dev_nix() {
if command -v nix-collect-garbage > /dev/null 2>&1; then if command -v nix-collect-garbage > /dev/null 2>&1; then
if [[ "$DRY_RUN" != "true" ]]; then if [[ "$DRY_RUN" != "true" ]]; then
clean_tool_cache "Nix garbage collection" nix-collect-garbage --delete-older-than 30d clean_tool_cache "Nix garbage collection" nix-collect-garbage --delete-older-than 30d
else else
echo -e " ${YELLOW}${NC} Nix garbage collection (would clean)" echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Nix garbage collection · would clean"
fi fi
note_activity note_activity
fi fi
} }
# Cloud CLI caches.
# Clean cloud CLI tools cache
clean_dev_cloud() { clean_dev_cloud() {
safe_clean ~/.kube/cache/* "Kubernetes cache" safe_clean ~/.kube/cache/* "Kubernetes cache"
safe_clean ~/.local/share/containers/storage/tmp/* "Container storage temp" safe_clean ~/.local/share/containers/storage/tmp/* "Container storage temp"
@@ -122,11 +116,8 @@ clean_dev_cloud() {
safe_clean ~/.config/gcloud/logs/* "Google Cloud logs" safe_clean ~/.config/gcloud/logs/* "Google Cloud logs"
safe_clean ~/.azure/logs/* "Azure CLI logs" safe_clean ~/.azure/logs/* "Azure CLI logs"
} }
# Frontend build caches.
# Clean frontend build tool caches
clean_dev_frontend() { clean_dev_frontend() {
safe_clean ~/.pnpm-store/* "pnpm store cache"
safe_clean ~/.local/share/pnpm/store/* "pnpm global store"
safe_clean ~/.cache/typescript/* "TypeScript cache" safe_clean ~/.cache/typescript/* "TypeScript cache"
safe_clean ~/.cache/electron/* "Electron cache" safe_clean ~/.cache/electron/* "Electron cache"
safe_clean ~/.cache/node-gyp/* "node-gyp cache" safe_clean ~/.cache/node-gyp/* "node-gyp cache"
@@ -139,49 +130,30 @@ clean_dev_frontend() {
safe_clean ~/.cache/eslint/* "ESLint cache" safe_clean ~/.cache/eslint/* "ESLint cache"
safe_clean ~/.cache/prettier/* "Prettier cache" safe_clean ~/.cache/prettier/* "Prettier cache"
} }
# Mobile dev caches (can be large).
# Clean mobile development tools
# iOS simulator cleanup can free significant space (70GB+ in some cases)
# DeviceSupport files accumulate for each iOS version connected
# Simulator runtime caches can grow large over time
clean_dev_mobile() { clean_dev_mobile() {
# Clean Xcode unavailable simulators
# Removes old and unused local iOS simulator data from old unused runtimes
# Can free up significant space (70GB+ in some cases)
if command -v xcrun > /dev/null 2>&1; then if command -v xcrun > /dev/null 2>&1; then
debug_log "Checking for unavailable Xcode simulators" debug_log "Checking for unavailable Xcode simulators"
if [[ "$DRY_RUN" == "true" ]]; then if [[ "$DRY_RUN" == "true" ]]; then
clean_tool_cache "Xcode unavailable simulators" xcrun simctl delete unavailable clean_tool_cache "Xcode unavailable simulators" xcrun simctl delete unavailable
else else
if [[ -t 1 ]]; then start_section_spinner "Checking unavailable simulators..."
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking unavailable simulators..."
fi
# Run command manually to control UI output order
if xcrun simctl delete unavailable > /dev/null 2>&1; then if xcrun simctl delete unavailable > /dev/null 2>&1; then
if [[ -t 1 ]]; then stop_inline_spinner; fi stop_section_spinner
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators"
else else
if [[ -t 1 ]]; then stop_inline_spinner; fi stop_section_spinner
# Silently fail or log error if needed, matching clean_tool_cache behavior
fi fi
fi fi
note_activity note_activity
fi fi
# DeviceSupport caches/logs (preserve core support files).
# Clean iOS DeviceSupport - more comprehensive cleanup
# DeviceSupport directories store debug symbols for each iOS version
# Safe to clean caches and logs, but preserve device support files themselves
safe_clean ~/Library/Developer/Xcode/iOS\ DeviceSupport/*/Symbols/System/Library/Caches/* "iOS device symbol cache" safe_clean ~/Library/Developer/Xcode/iOS\ DeviceSupport/*/Symbols/System/Library/Caches/* "iOS device symbol cache"
safe_clean ~/Library/Developer/Xcode/iOS\ DeviceSupport/*.log "iOS device support logs" safe_clean ~/Library/Developer/Xcode/iOS\ DeviceSupport/*.log "iOS device support logs"
safe_clean ~/Library/Developer/Xcode/watchOS\ DeviceSupport/*/Symbols/System/Library/Caches/* "watchOS device symbol cache" safe_clean ~/Library/Developer/Xcode/watchOS\ DeviceSupport/*/Symbols/System/Library/Caches/* "watchOS device symbol cache"
safe_clean ~/Library/Developer/Xcode/tvOS\ DeviceSupport/*/Symbols/System/Library/Caches/* "tvOS device symbol cache" safe_clean ~/Library/Developer/Xcode/tvOS\ DeviceSupport/*/Symbols/System/Library/Caches/* "tvOS device symbol cache"
# Simulator runtime caches.
# Clean simulator runtime caches
# RuntimeRoot caches can accumulate system library caches
safe_clean ~/Library/Developer/CoreSimulator/Profiles/Runtimes/*/Contents/Resources/RuntimeRoot/System/Library/Caches/* "Simulator runtime cache" safe_clean ~/Library/Developer/CoreSimulator/Profiles/Runtimes/*/Contents/Resources/RuntimeRoot/System/Library/Caches/* "Simulator runtime cache"
safe_clean ~/Library/Caches/Google/AndroidStudio*/* "Android Studio cache" safe_clean ~/Library/Caches/Google/AndroidStudio*/* "Android Studio cache"
safe_clean ~/Library/Caches/CocoaPods/* "CocoaPods cache" safe_clean ~/Library/Caches/CocoaPods/* "CocoaPods cache"
safe_clean ~/.cache/flutter/* "Flutter cache" safe_clean ~/.cache/flutter/* "Flutter cache"
@@ -190,16 +162,14 @@ clean_dev_mobile() {
safe_clean ~/Library/Developer/Xcode/UserData/IB\ Support/* "Xcode Interface Builder cache" safe_clean ~/Library/Developer/Xcode/UserData/IB\ Support/* "Xcode Interface Builder cache"
safe_clean ~/.cache/swift-package-manager/* "Swift package manager cache" safe_clean ~/.cache/swift-package-manager/* "Swift package manager cache"
} }
# JVM ecosystem caches.
# Clean JVM ecosystem tools
clean_dev_jvm() { clean_dev_jvm() {
safe_clean ~/.gradle/caches/* "Gradle caches" safe_clean ~/.gradle/caches/* "Gradle caches"
safe_clean ~/.gradle/daemon/* "Gradle daemon logs" safe_clean ~/.gradle/daemon/* "Gradle daemon logs"
safe_clean ~/.sbt/* "SBT cache" safe_clean ~/.sbt/* "SBT cache"
safe_clean ~/.ivy2/cache/* "Ivy cache" safe_clean ~/.ivy2/cache/* "Ivy cache"
} }
# Other language tool caches.
# Clean other language tools
clean_dev_other_langs() { clean_dev_other_langs() {
safe_clean ~/.bundle/cache/* "Ruby Bundler cache" safe_clean ~/.bundle/cache/* "Ruby Bundler cache"
safe_clean ~/.composer/cache/* "PHP Composer cache" safe_clean ~/.composer/cache/* "PHP Composer cache"
@@ -209,8 +179,7 @@ clean_dev_other_langs() {
safe_clean ~/.cache/zig/* "Zig cache" safe_clean ~/.cache/zig/* "Zig cache"
safe_clean ~/Library/Caches/deno/* "Deno cache" safe_clean ~/Library/Caches/deno/* "Deno cache"
} }
# CI/CD and DevOps caches.
# Clean CI/CD and DevOps tools
clean_dev_cicd() { clean_dev_cicd() {
safe_clean ~/.cache/terraform/* "Terraform cache" safe_clean ~/.cache/terraform/* "Terraform cache"
safe_clean ~/.grafana/cache/* "Grafana cache" safe_clean ~/.grafana/cache/* "Grafana cache"
@@ -221,8 +190,7 @@ clean_dev_cicd() {
safe_clean ~/.circleci/cache/* "CircleCI cache" safe_clean ~/.circleci/cache/* "CircleCI cache"
safe_clean ~/.sonar/* "SonarQube cache" safe_clean ~/.sonar/* "SonarQube cache"
} }
# Database tool caches.
# Clean database tools
clean_dev_database() { clean_dev_database() {
safe_clean ~/Library/Caches/com.sequel-ace.sequel-ace/* "Sequel Ace cache" safe_clean ~/Library/Caches/com.sequel-ace.sequel-ace/* "Sequel Ace cache"
safe_clean ~/Library/Caches/com.eggerapps.Sequel-Pro/* "Sequel Pro cache" safe_clean ~/Library/Caches/com.eggerapps.Sequel-Pro/* "Sequel Pro cache"
@@ -231,8 +199,7 @@ clean_dev_database() {
safe_clean ~/Library/Caches/com.dbeaver.* "DBeaver cache" safe_clean ~/Library/Caches/com.dbeaver.* "DBeaver cache"
safe_clean ~/Library/Caches/com.redis.RedisInsight "Redis Insight cache" safe_clean ~/Library/Caches/com.redis.RedisInsight "Redis Insight cache"
} }
# API/debugging tool caches.
# Clean API/network debugging tools
clean_dev_api_tools() { clean_dev_api_tools() {
safe_clean ~/Library/Caches/com.postmanlabs.mac/* "Postman cache" safe_clean ~/Library/Caches/com.postmanlabs.mac/* "Postman cache"
safe_clean ~/Library/Caches/com.konghq.insomnia/* "Insomnia cache" safe_clean ~/Library/Caches/com.konghq.insomnia/* "Insomnia cache"
@@ -241,11 +208,9 @@ clean_dev_api_tools() {
safe_clean ~/Library/Caches/com.charlesproxy.charles/* "Charles Proxy cache" safe_clean ~/Library/Caches/com.charlesproxy.charles/* "Charles Proxy cache"
safe_clean ~/Library/Caches/com.proxyman.NSProxy/* "Proxyman cache" safe_clean ~/Library/Caches/com.proxyman.NSProxy/* "Proxyman cache"
} }
# Misc dev tool caches.
# Clean misc dev tools
clean_dev_misc() { clean_dev_misc() {
safe_clean ~/Library/Caches/com.unity3d.*/* "Unity cache" safe_clean ~/Library/Caches/com.unity3d.*/* "Unity cache"
# safe_clean ~/Library/Caches/com.jetbrains.toolbox/* "JetBrains Toolbox cache"
safe_clean ~/Library/Caches/com.mongodb.compass/* "MongoDB Compass cache" safe_clean ~/Library/Caches/com.mongodb.compass/* "MongoDB Compass cache"
safe_clean ~/Library/Caches/com.figma.Desktop/* "Figma cache" safe_clean ~/Library/Caches/com.figma.Desktop/* "Figma cache"
safe_clean ~/Library/Caches/com.github.GitHubDesktop/* "GitHub Desktop cache" safe_clean ~/Library/Caches/com.github.GitHubDesktop/* "GitHub Desktop cache"
@@ -253,8 +218,7 @@ clean_dev_misc() {
safe_clean ~/Library/Caches/KSCrash/* "KSCrash reports" safe_clean ~/Library/Caches/KSCrash/* "KSCrash reports"
safe_clean ~/Library/Caches/com.crashlytics.data/* "Crashlytics data" safe_clean ~/Library/Caches/com.crashlytics.data/* "Crashlytics data"
} }
# Shell and VCS leftovers.
# Clean shell and version control
clean_dev_shell() { clean_dev_shell() {
safe_clean ~/.gitconfig.lock "Git config lock" safe_clean ~/.gitconfig.lock "Git config lock"
safe_clean ~/.gitconfig.bak* "Git config backup" safe_clean ~/.gitconfig.bak* "Git config backup"
@@ -264,19 +228,21 @@ clean_dev_shell() {
safe_clean ~/.zsh_history.bak* "Zsh history backup" safe_clean ~/.zsh_history.bak* "Zsh history backup"
safe_clean ~/.cache/pre-commit/* "pre-commit cache" safe_clean ~/.cache/pre-commit/* "pre-commit cache"
} }
# Network tool caches.
# Clean network utilities
clean_dev_network() { clean_dev_network() {
safe_clean ~/.cache/curl/* "curl cache" safe_clean ~/.cache/curl/* "curl cache"
safe_clean ~/.cache/wget/* "wget cache" safe_clean ~/.cache/wget/* "wget cache"
safe_clean ~/Library/Caches/curl/* "curl cache (macOS)" safe_clean ~/Library/Caches/curl/* "macOS curl cache"
safe_clean ~/Library/Caches/wget/* "wget cache (macOS)" safe_clean ~/Library/Caches/wget/* "macOS wget cache"
} }
# Orphaned SQLite temp files (-shm/-wal). Disabled due to low ROI.
# Main developer tools cleanup function clean_sqlite_temp_files() {
# Calls all specialized cleanup functions return 0
# Env: DRY_RUN }
# Main developer tools cleanup sequence.
clean_developer_tools() { clean_developer_tools() {
stop_section_spinner
clean_sqlite_temp_files
clean_dev_npm clean_dev_npm
clean_dev_python clean_dev_python
clean_dev_go clean_dev_go
@@ -286,10 +252,7 @@ clean_developer_tools() {
clean_dev_nix clean_dev_nix
clean_dev_shell clean_dev_shell
clean_dev_frontend clean_dev_frontend
# Project build caches (delegated to clean_caches module)
clean_project_caches clean_project_caches
clean_dev_mobile clean_dev_mobile
clean_dev_jvm clean_dev_jvm
clean_dev_other_langs clean_dev_other_langs
@@ -298,29 +261,20 @@ clean_developer_tools() {
clean_dev_api_tools clean_dev_api_tools
clean_dev_network clean_dev_network
clean_dev_misc clean_dev_misc
# Homebrew caches and cleanup (delegated to clean_brew module)
safe_clean ~/Library/Caches/Homebrew/* "Homebrew cache" safe_clean ~/Library/Caches/Homebrew/* "Homebrew cache"
# Clean Homebrew locks without repeated sudo prompts.
# Clean Homebrew locks intelligently (avoid repeated sudo prompts)
local brew_lock_dirs=( local brew_lock_dirs=(
"/opt/homebrew/var/homebrew/locks" "/opt/homebrew/var/homebrew/locks"
"/usr/local/var/homebrew/locks" "/usr/local/var/homebrew/locks"
) )
for lock_dir in "${brew_lock_dirs[@]}"; do for lock_dir in "${brew_lock_dirs[@]}"; do
if [[ -d "$lock_dir" && -w "$lock_dir" ]]; then if [[ -d "$lock_dir" && -w "$lock_dir" ]]; then
# User can write, safe to clean
safe_clean "$lock_dir"/* "Homebrew lock files" safe_clean "$lock_dir"/* "Homebrew lock files"
elif [[ -d "$lock_dir" ]]; then elif [[ -d "$lock_dir" ]]; then
# Directory exists but not writable. Check if empty to avoid noise. if find "$lock_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then
if [[ -n "$(ls -A "$lock_dir" 2> /dev/null)" ]]; then
# Only try sudo ONCE if we really need to, or just skip to avoid spam
# Decision: Skip strict system/root owned locks to avoid nag.
debug_log "Skipping read-only Homebrew locks in $lock_dir" debug_log "Skipping read-only Homebrew locks in $lock_dir"
fi fi
fi fi
done done
clean_homebrew clean_homebrew
} }

891
lib/clean/project.sh Normal file
View File

@@ -0,0 +1,891 @@
#!/bin/bash
# Project Purge Module (mo purge).
# Removes heavy project build artifacts and dependencies.
set -euo pipefail
PROJECT_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CORE_LIB_DIR="$(cd "$PROJECT_LIB_DIR/../core" && pwd)"
if ! command -v ensure_user_dir > /dev/null 2>&1; then
# shellcheck disable=SC1090
source "$CORE_LIB_DIR/common.sh"
fi
# Targets to look for (heavy build artifacts).
readonly PURGE_TARGETS=(
"node_modules"
"target" # Rust, Maven
"build" # Gradle, various
"dist" # JS builds
"venv" # Python
".venv" # Python
".gradle" # Gradle local
"__pycache__" # Python
".next" # Next.js
".nuxt" # Nuxt.js
".output" # Nuxt.js
"vendor" # PHP Composer
"obj" # C# / Unity
".turbo" # Turborepo cache
".parcel-cache" # Parcel bundler
".dart_tool" # Flutter/Dart build cache
".zig-cache" # Zig
"zig-out" # Zig
)
# Minimum age in days before considering for cleanup.
readonly MIN_AGE_DAYS=7
# Scan depth defaults (relative to search root).
readonly PURGE_MIN_DEPTH_DEFAULT=2
readonly PURGE_MAX_DEPTH_DEFAULT=8
# Search paths (default, can be overridden via config file).
readonly DEFAULT_PURGE_SEARCH_PATHS=(
"$HOME/www"
"$HOME/dev"
"$HOME/Projects"
"$HOME/GitHub"
"$HOME/Code"
"$HOME/Workspace"
"$HOME/Repos"
"$HOME/Development"
)
# Config file for custom purge paths.
readonly PURGE_CONFIG_FILE="$HOME/.config/mole/purge_paths"
# Resolved search paths.
PURGE_SEARCH_PATHS=()
# Project indicators for container detection.
readonly PROJECT_INDICATORS=(
"package.json"
"Cargo.toml"
"go.mod"
"pyproject.toml"
"requirements.txt"
"pom.xml"
"build.gradle"
"Gemfile"
"composer.json"
"pubspec.yaml"
"Makefile"
"build.zig"
"build.zig.zon"
".git"
)
# Check if a directory contains projects (directly or in subdirectories).
is_project_container() {
local dir="$1"
local max_depth="${2:-2}"
# Skip hidden/system directories.
local basename
basename=$(basename "$dir")
[[ "$basename" == .* ]] && return 1
[[ "$basename" == "Library" ]] && return 1
[[ "$basename" == "Applications" ]] && return 1
[[ "$basename" == "Movies" ]] && return 1
[[ "$basename" == "Music" ]] && return 1
[[ "$basename" == "Pictures" ]] && return 1
[[ "$basename" == "Public" ]] && return 1
# Single find expression for indicators.
local -a find_args=("$dir" "-maxdepth" "$max_depth" "(")
local first=true
for indicator in "${PROJECT_INDICATORS[@]}"; do
if [[ "$first" == "true" ]]; then
first=false
else
find_args+=("-o")
fi
find_args+=("-name" "$indicator")
done
find_args+=(")" "-print" "-quit")
if find "${find_args[@]}" 2> /dev/null | grep -q .; then
return 0
fi
return 1
}
# Discover project directories in $HOME.
discover_project_dirs() {
local -a discovered=()
for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do
if [[ -d "$path" ]]; then
discovered+=("$path")
fi
done
# Scan $HOME for other containers (depth 1).
local dir
for dir in "$HOME"/*/; do
[[ ! -d "$dir" ]] && continue
dir="${dir%/}" # Remove trailing slash
local already_found=false
for existing in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do
if [[ "$dir" == "$existing" ]]; then
already_found=true
break
fi
done
[[ "$already_found" == "true" ]] && continue
if is_project_container "$dir" 2; then
discovered+=("$dir")
fi
done
printf '%s\n' "${discovered[@]}" | sort -u
}
# Save discovered paths to config.
save_discovered_paths() {
local -a paths=("$@")
ensure_user_dir "$(dirname "$PURGE_CONFIG_FILE")"
cat > "$PURGE_CONFIG_FILE" << 'EOF'
# Mole Purge Paths - Auto-discovered project directories
# Edit this file to customize, or run: mo purge --paths
# Add one path per line (supports ~ for home directory)
EOF
printf '\n' >> "$PURGE_CONFIG_FILE"
for path in "${paths[@]}"; do
# Convert $HOME to ~ for portability
path="${path/#$HOME/~}"
echo "$path" >> "$PURGE_CONFIG_FILE"
done
}
# Load purge paths from config or auto-discover
load_purge_config() {
PURGE_SEARCH_PATHS=()
if [[ -f "$PURGE_CONFIG_FILE" ]]; then
while IFS= read -r line; do
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" || "$line" =~ ^# ]] && continue
line="${line/#\~/$HOME}"
PURGE_SEARCH_PATHS+=("$line")
done < "$PURGE_CONFIG_FILE"
fi
if [[ ${#PURGE_SEARCH_PATHS[@]} -eq 0 ]]; then
if [[ -t 1 ]] && [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]]; then
echo -e "${GRAY}First run: discovering project directories...${NC}" >&2
fi
local -a discovered=()
while IFS= read -r path; do
[[ -n "$path" ]] && discovered+=("$path")
done < <(discover_project_dirs)
if [[ ${#discovered[@]} -gt 0 ]]; then
PURGE_SEARCH_PATHS=("${discovered[@]}")
save_discovered_paths "${discovered[@]}"
if [[ -t 1 ]] && [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]]; then
echo -e "${GRAY}Found ${#discovered[@]} project directories, saved to config${NC}" >&2
fi
else
PURGE_SEARCH_PATHS=("${DEFAULT_PURGE_SEARCH_PATHS[@]}")
fi
fi
}
# Initialize paths on script load.
load_purge_config
# Args: $1 - path to check
# Safe cleanup requires the path be inside a project directory.
is_safe_project_artifact() {
local path="$1"
local search_path="$2"
if [[ "$path" != /* ]]; then
return 1
fi
# Must not be a direct child of the search root.
local relative_path="${path#"$search_path"/}"
local depth=$(echo "$relative_path" | tr -cd '/' | wc -c)
if [[ $depth -lt 1 ]]; then
return 1
fi
return 0
}
# Detect if directory is a Rails project root
is_rails_project_root() {
local dir="$1"
[[ -f "$dir/config/application.rb" ]] || return 1
[[ -f "$dir/Gemfile" ]] || return 1
[[ -f "$dir/bin/rails" || -f "$dir/config/environment.rb" ]]
}
# Detect if directory is a Go project root
is_go_project_root() {
local dir="$1"
[[ -f "$dir/go.mod" ]]
}
# Detect if directory is a PHP Composer project root
is_php_project_root() {
local dir="$1"
[[ -f "$dir/composer.json" ]]
}
# Check if a vendor directory should be protected from purge
# Expects path to be a vendor directory (basename == vendor)
# Strategy: Only clean PHP Composer vendor, protect all others
is_protected_vendor_dir() {
local path="$1"
local base
base=$(basename "$path")
[[ "$base" == "vendor" ]] || return 1
local parent_dir
parent_dir=$(dirname "$path")
# PHP Composer vendor can be safely regenerated with 'composer install'
# Do NOT protect it (return 1 = not protected = can be cleaned)
if is_php_project_root "$parent_dir"; then
return 1
fi
# Rails vendor (importmap dependencies) - should be protected
if is_rails_project_root "$parent_dir"; then
return 0
fi
# Go vendor (optional vendoring) - protect to avoid accidental deletion
if is_go_project_root "$parent_dir"; then
return 0
fi
# Unknown vendor type - protect by default (conservative approach)
return 0
}
# Check if an artifact should be protected from purge
is_protected_purge_artifact() {
local path="$1"
local base
base=$(basename "$path")
case "$base" in
vendor)
is_protected_vendor_dir "$path"
return $?
;;
esac
return 1
}
# Scan purge targets using fd (fast) or pruned find.
scan_purge_targets() {
local search_path="$1"
local output_file="$2"
local min_depth="${MOLE_PURGE_MIN_DEPTH:-$PURGE_MIN_DEPTH_DEFAULT}"
local max_depth="${MOLE_PURGE_MAX_DEPTH:-$PURGE_MAX_DEPTH_DEFAULT}"
if [[ ! "$min_depth" =~ ^[0-9]+$ ]]; then
min_depth="$PURGE_MIN_DEPTH_DEFAULT"
fi
if [[ ! "$max_depth" =~ ^[0-9]+$ ]]; then
max_depth="$PURGE_MAX_DEPTH_DEFAULT"
fi
if [[ "$max_depth" -lt "$min_depth" ]]; then
max_depth="$min_depth"
fi
if [[ ! -d "$search_path" ]]; then
return
fi
if command -v fd > /dev/null 2>&1; then
# Escape regex special characters in target names for fd patterns
local escaped_targets=()
for target in "${PURGE_TARGETS[@]}"; do
escaped_targets+=("$(printf '%s' "$target" | sed -e 's/[][(){}.^$*+?|\\]/\\&/g')")
done
local pattern="($(
IFS='|'
echo "${escaped_targets[*]}"
))"
local fd_args=(
"--absolute-path"
"--hidden"
"--no-ignore"
"--type" "d"
"--min-depth" "$min_depth"
"--max-depth" "$max_depth"
"--threads" "4"
"--exclude" ".git"
"--exclude" "Library"
"--exclude" ".Trash"
"--exclude" "Applications"
)
fd "${fd_args[@]}" "$pattern" "$search_path" 2> /dev/null | while IFS= read -r item; do
if is_safe_project_artifact "$item" "$search_path"; then
echo "$item"
fi
done | filter_nested_artifacts | filter_protected_artifacts > "$output_file"
else
# Pruned find avoids descending into heavy directories.
local prune_args=()
local prune_dirs=(".git" "Library" ".Trash" "Applications")
for dir in "${prune_dirs[@]}"; do
prune_args+=("-name" "$dir" "-prune" "-o")
done
for target in "${PURGE_TARGETS[@]}"; do
prune_args+=("-name" "$target" "-print" "-prune" "-o")
done
local find_expr=()
for dir in "${prune_dirs[@]}"; do
find_expr+=("-name" "$dir" "-prune" "-o")
done
local i=0
for target in "${PURGE_TARGETS[@]}"; do
find_expr+=("-name" "$target" "-print" "-prune")
if [[ $i -lt $((${#PURGE_TARGETS[@]} - 1)) ]]; then
find_expr+=("-o")
fi
((i++))
done
command find "$search_path" -mindepth "$min_depth" -maxdepth "$max_depth" -type d \
\( "${find_expr[@]}" \) 2> /dev/null | while IFS= read -r item; do
if is_safe_project_artifact "$item" "$search_path"; then
echo "$item"
fi
done | filter_nested_artifacts | filter_protected_artifacts > "$output_file"
fi
}
# Filter out nested artifacts (e.g. node_modules inside node_modules).
filter_nested_artifacts() {
while IFS= read -r item; do
local parent_dir=$(dirname "$item")
local is_nested=false
for target in "${PURGE_TARGETS[@]}"; do
if [[ "$parent_dir" == *"/$target/"* || "$parent_dir" == *"/$target" ]]; then
is_nested=true
break
fi
done
if [[ "$is_nested" == "false" ]]; then
echo "$item"
fi
done
}
filter_protected_artifacts() {
while IFS= read -r item; do
if ! is_protected_purge_artifact "$item"; then
echo "$item"
fi
done
}
# Args: $1 - path
# Check if a path was modified recently (safety check).
is_recently_modified() {
local path="$1"
local age_days=$MIN_AGE_DAYS
if [[ ! -e "$path" ]]; then
return 1
fi
local mod_time
mod_time=$(get_file_mtime "$path")
local current_time=$(date +%s)
local age_seconds=$((current_time - mod_time))
local age_in_days=$((age_seconds / 86400))
if [[ $age_in_days -lt $age_days ]]; then
return 0 # Recently modified
else
return 1 # Old enough to clean
fi
}
# Args: $1 - path
# Get directory size in KB.
get_dir_size_kb() {
local path="$1"
if [[ -d "$path" ]]; then
du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0"
else
echo "0"
fi
}
# Purge category selector.
select_purge_categories() {
local -a categories=("$@")
local total_items=${#categories[@]}
local clear_line=$'\r\033[2K'
if [[ $total_items -eq 0 ]]; then
return 1
fi
# Calculate items per page based on terminal height.
_get_items_per_page() {
local term_height=24
if [[ -t 0 ]] || [[ -t 2 ]]; then
term_height=$(stty size < /dev/tty 2> /dev/null | awk '{print $1}')
fi
if [[ -z "$term_height" || $term_height -le 0 ]]; then
if command -v tput > /dev/null 2>&1; then
term_height=$(tput lines 2> /dev/null || echo "24")
else
term_height=24
fi
fi
local reserved=6
local available=$((term_height - reserved))
if [[ $available -lt 3 ]]; then
echo 3
elif [[ $available -gt 50 ]]; then
echo 50
else
echo "$available"
fi
}
local items_per_page=$(_get_items_per_page)
local cursor_pos=0
local top_index=0
# Initialize selection (all selected by default, except recent ones)
local -a selected=()
IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}"
for ((i = 0; i < total_items; i++)); do
# Default unselected if category has recent items
if [[ ${recent_flags[i]:-false} == "true" ]]; then
selected[i]=false
else
selected[i]=true
fi
done
local original_stty=""
if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then
original_stty=$(stty -g 2> /dev/null || echo "")
fi
# Terminal control functions
restore_terminal() {
trap - EXIT INT TERM
show_cursor
if [[ -n "${original_stty:-}" ]]; then
stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true
fi
}
# shellcheck disable=SC2329
handle_interrupt() {
restore_terminal
exit 130
}
draw_menu() {
# Recalculate items_per_page dynamically to handle window resize
items_per_page=$(_get_items_per_page)
# Clamp pagination state to avoid cursor drifting out of view
local max_top_index=0
if [[ $total_items -gt $items_per_page ]]; then
max_top_index=$((total_items - items_per_page))
fi
if [[ $top_index -gt $max_top_index ]]; then
top_index=$max_top_index
fi
if [[ $top_index -lt 0 ]]; then
top_index=0
fi
local visible_count=$((total_items - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -gt $((visible_count - 1)) ]]; then
cursor_pos=$((visible_count - 1))
fi
if [[ $cursor_pos -lt 0 ]]; then
cursor_pos=0
fi
printf "\033[H"
# Calculate total size of selected items for header
local selected_size=0
local selected_count=0
IFS=',' read -r -a sizes <<< "${PURGE_CATEGORY_SIZES:-}"
for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then
selected_size=$((selected_size + ${sizes[i]:-0}))
((selected_count++))
fi
done
local selected_gb
selected_gb=$(printf "%.1f" "$(echo "scale=2; $selected_size/1024/1024" | bc)")
# Show position indicator if scrolling is needed
local scroll_indicator=""
if [[ $total_items -gt $items_per_page ]]; then
local current_pos=$((top_index + cursor_pos + 1))
scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}"
fi
printf "%s\n" "$clear_line"
printf "%s${PURPLE_BOLD}Select Categories to Clean${NC}%s ${GRAY}- ${selected_gb}GB ($selected_count selected)${NC}\n" "$clear_line" "$scroll_indicator"
printf "%s\n" "$clear_line"
IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}"
# Calculate visible range
local end_index=$((top_index + visible_count))
# Draw only visible items
for ((i = top_index; i < end_index; i++)); do
local checkbox="$ICON_EMPTY"
[[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID"
local recent_marker=""
[[ ${recent_flags[i]:-false} == "true" ]] && recent_marker=" ${GRAY}| Recent${NC}"
local rel_pos=$((i - top_index))
if [[ $rel_pos -eq $cursor_pos ]]; then
printf "%s${CYAN}${ICON_ARROW} %s %s%s${NC}\n" "$clear_line" "$checkbox" "${categories[i]}" "$recent_marker"
else
printf "%s %s %s%s\n" "$clear_line" "$checkbox" "${categories[i]}" "$recent_marker"
fi
done
# Fill empty slots to clear previous content
local items_shown=$visible_count
for ((i = items_shown; i < items_per_page; i++)); do
printf "%s\n" "$clear_line"
done
printf "%s\n" "$clear_line"
printf "%s${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space Select | Enter Confirm | A All | I Invert | Q Quit${NC}\n" "$clear_line"
}
trap restore_terminal EXIT
trap handle_interrupt INT TERM
# Preserve interrupt character for Ctrl-C
stty -echo -icanon intr ^C 2> /dev/null || true
hide_cursor
if [[ -t 1 ]]; then
clear_screen
fi
# Main loop
while true; do
draw_menu
# Read key
IFS= read -r -s -n1 key || key=""
case "$key" in
$'\x1b')
# Arrow keys or ESC
# Read next 2 chars with timeout (bash 3.2 needs integer)
IFS= read -r -s -n1 -t 1 key2 || key2=""
if [[ "$key2" == "[" ]]; then
IFS= read -r -s -n1 -t 1 key3 || key3=""
case "$key3" in
A) # Up arrow
if [[ $cursor_pos -gt 0 ]]; then
((cursor_pos--))
elif [[ $top_index -gt 0 ]]; then
((top_index--))
fi
;;
B) # Down arrow
local absolute_index=$((top_index + cursor_pos))
local last_index=$((total_items - 1))
if [[ $absolute_index -lt $last_index ]]; then
local visible_count=$((total_items - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
((cursor_pos++))
elif [[ $((top_index + visible_count)) -lt $total_items ]]; then
((top_index++))
fi
fi
;;
esac
else
# ESC alone (no following chars)
restore_terminal
return 1
fi
;;
" ") # Space - toggle current item
local idx=$((top_index + cursor_pos))
if [[ ${selected[idx]} == true ]]; then
selected[idx]=false
else
selected[idx]=true
fi
;;
"a" | "A") # Select all
for ((i = 0; i < total_items; i++)); do
selected[i]=true
done
;;
"i" | "I") # Invert selection
for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then
selected[i]=false
else
selected[i]=true
fi
done
;;
"q" | "Q" | $'\x03') # Quit or Ctrl-C
restore_terminal
return 1
;;
"" | $'\n' | $'\r') # Enter - confirm
# Build result
PURGE_SELECTION_RESULT=""
for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then
[[ -n "$PURGE_SELECTION_RESULT" ]] && PURGE_SELECTION_RESULT+=","
PURGE_SELECTION_RESULT+="$i"
fi
done
restore_terminal
return 0
;;
esac
done
}
# Main cleanup function - scans and prompts user to select artifacts to clean
clean_project_artifacts() {
local -a all_found_items=()
local -a safe_to_clean=()
local -a recently_modified=()
# Set up cleanup on interrupt
# Note: Declared without 'local' so cleanup_scan trap can access them
scan_pids=()
scan_temps=()
# shellcheck disable=SC2329
cleanup_scan() {
# Kill all background scans
for pid in "${scan_pids[@]+"${scan_pids[@]}"}"; do
kill "$pid" 2> /dev/null || true
done
# Clean up temp files
for temp in "${scan_temps[@]+"${scan_temps[@]}"}"; do
rm -f "$temp" 2> /dev/null || true
done
if [[ -t 1 ]]; then
stop_inline_spinner
fi
echo ""
exit 130
}
trap cleanup_scan INT TERM
# Start parallel scanning of all paths at once
if [[ -t 1 ]]; then
start_inline_spinner "Scanning projects..."
fi
# Launch all scans in parallel
for path in "${PURGE_SEARCH_PATHS[@]}"; do
if [[ -d "$path" ]]; then
local scan_output
scan_output=$(mktemp)
scan_temps+=("$scan_output")
# Launch scan in background for true parallelism
scan_purge_targets "$path" "$scan_output" &
local scan_pid=$!
scan_pids+=("$scan_pid")
fi
done
# Wait for all scans to complete
for pid in "${scan_pids[@]+"${scan_pids[@]}"}"; do
wait "$pid" 2> /dev/null || true
done
if [[ -t 1 ]]; then
stop_inline_spinner
fi
# Collect all results
for scan_output in "${scan_temps[@]+"${scan_temps[@]}"}"; do
if [[ -f "$scan_output" ]]; then
while IFS= read -r item; do
if [[ -n "$item" ]]; then
all_found_items+=("$item")
fi
done < "$scan_output"
rm -f "$scan_output"
fi
done
# Clean up trap
trap - INT TERM
if [[ ${#all_found_items[@]} -eq 0 ]]; then
echo ""
echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No old project artifacts to clean"
printf '\n'
return 2 # Special code: nothing to clean
fi
# Mark recently modified items (for default selection state)
for item in "${all_found_items[@]}"; do
if is_recently_modified "$item"; then
recently_modified+=("$item")
fi
# Add all items to safe_to_clean, let user choose
safe_to_clean+=("$item")
done
# Build menu options - one per artifact
if [[ -t 1 ]]; then
start_inline_spinner "Calculating sizes..."
fi
local -a menu_options=()
local -a item_paths=()
local -a item_sizes=()
local -a item_recent_flags=()
# Helper to get project name from path
# For ~/www/pake/src-tauri/target -> returns "pake"
# For ~/work/code/MyProject/node_modules -> returns "MyProject"
# Strategy: Find the nearest ancestor directory containing a project indicator file
get_project_name() {
local path="$1"
local artifact_name
artifact_name=$(basename "$path")
# Start from the parent of the artifact and walk up
local current_dir
current_dir=$(dirname "$path")
while [[ "$current_dir" != "/" && "$current_dir" != "$HOME" && -n "$current_dir" ]]; do
# Check if current directory contains any project indicator
for indicator in "${PROJECT_INDICATORS[@]}"; do
if [[ -e "$current_dir/$indicator" ]]; then
# Found a project root, return its name
basename "$current_dir"
return 0
fi
done
# Move up one level
current_dir=$(dirname "$current_dir")
done
# Fallback: try the old logic (first directory under search root)
local search_roots=()
if [[ ${#PURGE_SEARCH_PATHS[@]} -gt 0 ]]; then
search_roots=("${PURGE_SEARCH_PATHS[@]}")
else
search_roots=("$HOME/www" "$HOME/dev" "$HOME/Projects")
fi
for root in "${search_roots[@]}"; do
root="${root%/}"
if [[ -n "$root" && "$path" == "$root/"* ]]; then
local relative_path="${path#"$root"/}"
echo "$relative_path" | cut -d'/' -f1
return 0
fi
done
# Final fallback: use grandparent directory
dirname "$(dirname "$path")" | xargs basename
}
# Format display with alignment (like app_selector)
format_purge_display() {
local project_name="$1"
local artifact_type="$2"
local size_str="$3"
# Terminal width for alignment
local terminal_width=$(tput cols 2> /dev/null || echo 80)
local fixed_width=28 # Reserve for type and size
local available_width=$((terminal_width - fixed_width))
# Bounds: 24-35 chars for project name
[[ $available_width -lt 24 ]] && available_width=24
[[ $available_width -gt 35 ]] && available_width=35
# Truncate project name if needed
local truncated_name=$(truncate_by_display_width "$project_name" "$available_width")
local current_width=$(get_display_width "$truncated_name")
local char_count=${#truncated_name}
local padding=$((available_width - current_width))
local printf_width=$((char_count + padding))
# Format: "project_name size | artifact_type"
printf "%-*s %9s | %-13s" "$printf_width" "$truncated_name" "$size_str" "$artifact_type"
}
# Build menu options - one line per artifact
for item in "${safe_to_clean[@]}"; do
local project_name=$(get_project_name "$item")
local artifact_type=$(basename "$item")
local size_kb=$(get_dir_size_kb "$item")
local size_human=$(bytes_to_human "$((size_kb * 1024))")
# Check if recent
local is_recent=false
for recent_item in "${recently_modified[@]+"${recently_modified[@]}"}"; do
if [[ "$item" == "$recent_item" ]]; then
is_recent=true
break
fi
done
menu_options+=("$(format_purge_display "$project_name" "$artifact_type" "$size_human")")
item_paths+=("$item")
item_sizes+=("$size_kb")
item_recent_flags+=("$is_recent")
done
if [[ -t 1 ]]; then
stop_inline_spinner
fi
# Set global vars for selector
export PURGE_CATEGORY_SIZES=$(
IFS=,
echo "${item_sizes[*]}"
)
export PURGE_RECENT_CATEGORIES=$(
IFS=,
echo "${item_recent_flags[*]}"
)
# Interactive selection (only if terminal is available)
PURGE_SELECTION_RESULT=""
if [[ -t 0 ]]; then
if ! select_purge_categories "${menu_options[@]}"; then
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
return 1
fi
else
# Non-interactive: select all non-recent items
for ((i = 0; i < ${#menu_options[@]}; i++)); do
if [[ ${item_recent_flags[i]} != "true" ]]; then
[[ -n "$PURGE_SELECTION_RESULT" ]] && PURGE_SELECTION_RESULT+=","
PURGE_SELECTION_RESULT+="$i"
fi
done
fi
if [[ -z "$PURGE_SELECTION_RESULT" ]]; then
echo ""
echo -e "${GRAY}No items selected${NC}"
printf '\n'
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
return 0
fi
# Clean selected items
echo ""
IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT"
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
local cleaned_count=0
for idx in "${selected_indices[@]}"; do
local item_path="${item_paths[idx]}"
local artifact_type=$(basename "$item_path")
local project_name=$(get_project_name "$item_path")
local size_kb="${item_sizes[idx]}"
local size_human=$(bytes_to_human "$((size_kb * 1024))")
# Safety checks
if [[ -z "$item_path" || "$item_path" == "/" || "$item_path" == "$HOME" || "$item_path" != "$HOME/"* ]]; then
continue
fi
if [[ -t 1 ]]; then
start_inline_spinner "Cleaning $project_name/$artifact_type..."
fi
if [[ -e "$item_path" ]]; then
safe_remove "$item_path" true
if [[ ! -e "$item_path" ]]; then
local current_total=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0")
echo "$((current_total + size_kb))" > "$stats_dir/purge_stats"
((cleaned_count++))
fi
fi
if [[ -t 1 ]]; then
stop_inline_spinner
echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_name - $artifact_type ${GREEN}($size_human)${NC}"
fi
done
# Update count
echo "$cleaned_count" > "$stats_dir/purge_count"
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
}

View File

@@ -1,49 +1,36 @@
#!/bin/bash #!/bin/bash
# System-Level Cleanup Module # System-Level Cleanup Module (requires sudo).
# Deep system cleanup (requires sudo) and Time Machine failed backups
set -euo pipefail set -euo pipefail
# System caches, logs, and temp files.
# Deep system cleanup (requires sudo)
clean_deep_system() { clean_deep_system() {
# Clean old system caches stop_section_spinner
safe_sudo_find_delete "/Library/Caches" "*.cache" "$MOLE_TEMP_FILE_AGE_DAYS" "f" || true local cache_cleaned=0
safe_sudo_find_delete "/Library/Caches" "*.tmp" "$MOLE_TEMP_FILE_AGE_DAYS" "f" || true safe_sudo_find_delete "/Library/Caches" "*.cache" "$MOLE_TEMP_FILE_AGE_DAYS" "f" && cache_cleaned=1 || true
safe_sudo_find_delete "/Library/Caches" "*.log" "$MOLE_LOG_AGE_DAYS" "f" || true safe_sudo_find_delete "/Library/Caches" "*.tmp" "$MOLE_TEMP_FILE_AGE_DAYS" "f" && cache_cleaned=1 || true
safe_sudo_find_delete "/Library/Caches" "*.log" "$MOLE_LOG_AGE_DAYS" "f" && cache_cleaned=1 || true
# Clean temp files - use real paths (macOS /tmp is symlink to /private/tmp) [[ $cache_cleaned -eq 1 ]] && log_success "System caches"
local tmp_cleaned=0 local tmp_cleaned=0
safe_sudo_find_delete "/private/tmp" "*" "${MOLE_TEMP_FILE_AGE_DAYS}" "f" && tmp_cleaned=1 || true safe_sudo_find_delete "/private/tmp" "*" "${MOLE_TEMP_FILE_AGE_DAYS}" "f" && tmp_cleaned=1 || true
safe_sudo_find_delete "/private/var/tmp" "*" "${MOLE_TEMP_FILE_AGE_DAYS}" "f" && tmp_cleaned=1 || true safe_sudo_find_delete "/private/var/tmp" "*" "${MOLE_TEMP_FILE_AGE_DAYS}" "f" && tmp_cleaned=1 || true
[[ $tmp_cleaned -eq 1 ]] && log_success "System temp files" [[ $tmp_cleaned -eq 1 ]] && log_success "System temp files"
# Clean crash reports
safe_sudo_find_delete "/Library/Logs/DiagnosticReports" "*" "$MOLE_CRASH_REPORT_AGE_DAYS" "f" || true safe_sudo_find_delete "/Library/Logs/DiagnosticReports" "*" "$MOLE_CRASH_REPORT_AGE_DAYS" "f" || true
log_success "System crash reports" log_success "System crash reports"
# Clean system logs - use real path (macOS /var is symlink to /private/var)
safe_sudo_find_delete "/private/var/log" "*.log" "$MOLE_LOG_AGE_DAYS" "f" || true safe_sudo_find_delete "/private/var/log" "*.log" "$MOLE_LOG_AGE_DAYS" "f" || true
safe_sudo_find_delete "/private/var/log" "*.gz" "$MOLE_LOG_AGE_DAYS" "f" || true safe_sudo_find_delete "/private/var/log" "*.gz" "$MOLE_LOG_AGE_DAYS" "f" || true
log_success "System logs" log_success "System logs"
# Clean Library Updates safely - skip if SIP is enabled to avoid error messages
# SIP-protected files in /Library/Updates cannot be deleted even with sudo
if [[ -d "/Library/Updates" && ! -L "/Library/Updates" ]]; then if [[ -d "/Library/Updates" && ! -L "/Library/Updates" ]]; then
if is_sip_enabled; then if ! is_sip_enabled; then
# SIP is enabled, skip /Library/Updates entirely to avoid error messages
# These files are system-protected and cannot be removed
: # No-op, silently skip
else
# SIP is disabled, attempt cleanup with restricted flag check
local updates_cleaned=0 local updates_cleaned=0
while IFS= read -r -d '' item; do while IFS= read -r -d '' item; do
# Skip system-protected files (restricted flag) if [[ -z "$item" ]] || [[ ! "$item" =~ ^/Library/Updates/[^/]+$ ]]; then
debug_log "Skipping malformed path: $item"
continue
fi
local item_flags local item_flags
item_flags=$(command stat -f%Sf "$item" 2> /dev/null || echo "") item_flags=$($STAT_BSD -f%Sf "$item" 2> /dev/null || echo "")
if [[ "$item_flags" == *"restricted"* ]]; then if [[ "$item_flags" == *"restricted"* ]]; then
continue continue
fi fi
if safe_sudo_remove "$item"; then if safe_sudo_remove "$item"; then
((updates_cleaned++)) ((updates_cleaned++))
fi fi
@@ -51,21 +38,15 @@ clean_deep_system() {
[[ $updates_cleaned -gt 0 ]] && log_success "System library updates" [[ $updates_cleaned -gt 0 ]] && log_success "System library updates"
fi fi
fi fi
# Clean macOS Install Data (system upgrade leftovers)
# Only remove if older than 30 days to ensure system stability
if [[ -d "/macOS Install Data" ]]; then if [[ -d "/macOS Install Data" ]]; then
local mtime=$(get_file_mtime "/macOS Install Data") local mtime=$(get_file_mtime "/macOS Install Data")
local age_days=$((($(date +%s) - mtime) / 86400)) local age_days=$((($(date +%s) - mtime) / 86400))
debug_log "Found macOS Install Data (age: ${age_days} days)" debug_log "Found macOS Install Data (age: ${age_days} days)"
if [[ $age_days -ge 30 ]]; then if [[ $age_days -ge 30 ]]; then
local size_kb=$(get_path_size_kb "/macOS Install Data") local size_kb=$(get_path_size_kb "/macOS Install Data")
if [[ -n "$size_kb" && "$size_kb" -gt 0 ]]; then if [[ -n "$size_kb" && "$size_kb" -gt 0 ]]; then
local size_human=$(bytes_to_human "$((size_kb * 1024))") local size_human=$(bytes_to_human "$((size_kb * 1024))")
debug_log "Cleaning macOS Install Data: $size_human (${age_days} days old)" debug_log "Cleaning macOS Install Data: $size_human (${age_days} days old)"
if safe_sudo_remove "/macOS Install Data"; then if safe_sudo_remove "/macOS Install Data"; then
log_success "macOS Install Data ($size_human)" log_success "macOS Install Data ($size_human)"
fi fi
@@ -74,172 +55,175 @@ clean_deep_system() {
debug_log "Keeping macOS Install Data (only ${age_days} days old, needs 30+)" debug_log "Keeping macOS Install Data (only ${age_days} days old, needs 30+)"
fi fi
fi fi
start_section_spinner "Scanning system caches..."
# Clean browser code signature caches
# These are regenerated automatically when needed
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning system caches..."
fi
local code_sign_cleaned=0 local code_sign_cleaned=0
local found_count=0
local last_update_time=$(date +%s)
local update_interval=2
while IFS= read -r -d '' cache_dir; do while IFS= read -r -d '' cache_dir; do
debug_log "Found code sign cache: $cache_dir"
if safe_remove "$cache_dir" true; then if safe_remove "$cache_dir" true; then
((code_sign_cleaned++)) ((code_sign_cleaned++))
fi fi
done < <(find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true) ((found_count++))
local current_time=$(date +%s)
if [[ -t 1 ]]; then stop_inline_spinner; fi if [[ $((current_time - last_update_time)) -ge $update_interval ]]; then
start_section_spinner "Scanning system caches... ($found_count found)"
last_update_time=$current_time
fi
done < <(run_with_timeout 5 command find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true)
stop_section_spinner
[[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches ($code_sign_cleaned items)" [[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches ($code_sign_cleaned items)"
# Clean system diagnostics logs
safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*" "$MOLE_LOG_AGE_DAYS" "f" || true safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
safe_sudo_find_delete "/private/var/db/diagnostics/Persist" "*" "$MOLE_LOG_AGE_DAYS" "f" || true safe_sudo_find_delete "/private/var/db/diagnostics/Persist" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
safe_sudo_find_delete "/private/var/db/DiagnosticPipeline" "*" "$MOLE_LOG_AGE_DAYS" "f" || true safe_sudo_find_delete "/private/var/db/DiagnosticPipeline" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
log_success "System diagnostic logs" log_success "System diagnostic logs"
# Clean power logs
safe_sudo_find_delete "/private/var/db/powerlog" "*" "$MOLE_LOG_AGE_DAYS" "f" || true safe_sudo_find_delete "/private/var/db/powerlog" "*" "$MOLE_LOG_AGE_DAYS" "f" || true
log_success "Power logs" log_success "Power logs"
safe_sudo_find_delete "/private/var/db/reportmemoryexception/MemoryLimitViolations" "*" "30" "f" || true
log_success "Memory exception reports"
start_section_spinner "Cleaning diagnostic trace logs..."
local diag_logs_cleaned=0
safe_sudo_find_delete "/private/var/db/diagnostics/Persist" "*.tracev3" "30" "f" && diag_logs_cleaned=1 || true
safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*.tracev3" "30" "f" && diag_logs_cleaned=1 || true
stop_section_spinner
[[ $diag_logs_cleaned -eq 1 ]] && log_success "System diagnostic trace logs"
} }
# Incomplete Time Machine backups.
# Clean Time Machine failed backups
clean_time_machine_failed_backups() { clean_time_machine_failed_backups() {
local tm_cleaned=0 local tm_cleaned=0
if ! command -v tmutil > /dev/null 2>&1; then
# Check if Time Machine is configured echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
if command -v tmutil > /dev/null 2>&1; then
if tmutil destinationinfo 2>&1 | grep -q "No destinations configured"; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No failed Time Machine backups found"
return 0
fi
fi
if [[ ! -d "/Volumes" ]]; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No failed Time Machine backups found"
return 0 return 0
fi fi
start_section_spinner "Checking Time Machine configuration..."
# Skip if backup is running local spinner_active=true
if pgrep -x "backupd" > /dev/null 2>&1; then local tm_info
tm_info=$(run_with_timeout 2 tmutil destinationinfo 2>&1 || echo "failed")
if [[ "$tm_info" == *"No destinations configured"* || "$tm_info" == "failed" ]]; then
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
fi
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
return 0
fi
if [[ ! -d "/Volumes" ]]; then
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
fi
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
return 0
fi
if tmutil status 2> /dev/null | grep -q "Running = 1"; then
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
fi
echo -e " ${YELLOW}!${NC} Time Machine backup in progress, skipping cleanup" echo -e " ${YELLOW}!${NC} Time Machine backup in progress, skipping cleanup"
return 0 return 0
fi fi
if [[ "$spinner_active" == "true" ]]; then
start_section_spinner "Checking backup volumes..."
fi
# Fast pre-scan for backup volumes to avoid slow tmutil checks.
local -a backup_volumes=()
for volume in /Volumes/*; do for volume in /Volumes/*; do
[[ -d "$volume" ]] || continue [[ -d "$volume" ]] || continue
# Skip system and network volumes
[[ "$volume" == "/Volumes/MacintoshHD" || "$volume" == "/" ]] && continue [[ "$volume" == "/Volumes/MacintoshHD" || "$volume" == "/" ]] && continue
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning backup volumes..."
fi
# Skip if volume is a symlink (security check)
[[ -L "$volume" ]] && continue [[ -L "$volume" ]] && continue
if [[ -d "$volume/Backups.backupdb" ]] || [[ -d "$volume/.MobileBackups" ]]; then
# Check if this is a Time Machine destination backup_volumes+=("$volume")
if command -v tmutil > /dev/null 2>&1; then
if ! tmutil destinationinfo 2> /dev/null | grep -q "$(basename "$volume")"; then
continue
fi
fi fi
done
local fs_type=$(command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}') if [[ ${#backup_volumes[@]} -eq 0 ]]; then
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
fi
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
return 0
fi
if [[ "$spinner_active" == "true" ]]; then
start_section_spinner "Scanning backup volumes..."
fi
for volume in "${backup_volumes[@]}"; do
local fs_type
fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "unknown")
case "$fs_type" in case "$fs_type" in
nfs | smbfs | afpfs | cifs | webdav) continue ;; nfs | smbfs | afpfs | cifs | webdav | unknown) continue ;;
esac esac
# HFS+ style backups (Backups.backupdb)
local backupdb_dir="$volume/Backups.backupdb" local backupdb_dir="$volume/Backups.backupdb"
if [[ -d "$backupdb_dir" ]]; then if [[ -d "$backupdb_dir" ]]; then
while IFS= read -r inprogress_file; do while IFS= read -r inprogress_file; do
[[ -d "$inprogress_file" ]] || continue [[ -d "$inprogress_file" ]] || continue
# Only delete old incomplete backups (safety window).
# Only delete old failed backups (safety window)
local file_mtime=$(get_file_mtime "$inprogress_file") local file_mtime=$(get_file_mtime "$inprogress_file")
local current_time=$(date +%s) local current_time=$(date +%s)
local hours_old=$(((current_time - file_mtime) / 3600)) local hours_old=$(((current_time - file_mtime) / 3600))
if [[ $hours_old -lt $MOLE_TM_BACKUP_SAFE_HOURS ]]; then if [[ $hours_old -lt $MOLE_TM_BACKUP_SAFE_HOURS ]]; then
continue continue
fi fi
local size_kb=$(get_path_size_kb "$inprogress_file") local size_kb=$(get_path_size_kb "$inprogress_file")
[[ "$size_kb" -le 0 ]] && continue [[ "$size_kb" -le 0 ]] && continue
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
spinner_active=false
fi
local backup_name=$(basename "$inprogress_file") local backup_name=$(basename "$inprogress_file")
local size_human=$(bytes_to_human "$((size_kb * 1024))") local size_human=$(bytes_to_human "$((size_kb * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${NC} Failed backup: $backup_name ${YELLOW}($size_human dry)${NC}" echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete backup: $backup_name ${YELLOW}($size_human dry)${NC}"
((tm_cleaned++)) ((tm_cleaned++))
note_activity note_activity
continue continue
fi fi
# Real deletion
if ! command -v tmutil > /dev/null 2>&1; then if ! command -v tmutil > /dev/null 2>&1; then
echo -e " ${YELLOW}!${NC} tmutil not available, skipping: $backup_name" echo -e " ${YELLOW}!${NC} tmutil not available, skipping: $backup_name"
continue continue
fi fi
if tmutil delete "$inprogress_file" 2> /dev/null; then if tmutil delete "$inprogress_file" 2> /dev/null; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Failed backup: $backup_name ${GREEN}($size_human)${NC}" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name ${GREEN}($size_human)${NC}"
((tm_cleaned++)) ((tm_cleaned++))
((files_cleaned++)) ((files_cleaned++))
((total_size_cleaned += size_kb)) ((total_size_cleaned += size_kb))
((total_items++)) ((total_items++))
note_activity note_activity
else else
echo -e " ${YELLOW}!${NC} Could not delete: $backup_name (try manually with sudo)" echo -e " ${YELLOW}!${NC} Could not delete: $backup_name · try manually with sudo"
fi fi
done < <(run_with_timeout 15 find "$backupdb_dir" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) done < <(run_with_timeout 15 find "$backupdb_dir" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true)
fi fi
# APFS bundles.
# APFS style backups (.backupbundle or .sparsebundle)
for bundle in "$volume"/*.backupbundle "$volume"/*.sparsebundle; do for bundle in "$volume"/*.backupbundle "$volume"/*.sparsebundle; do
[[ -e "$bundle" ]] || continue [[ -e "$bundle" ]] || continue
[[ -d "$bundle" ]] || continue [[ -d "$bundle" ]] || continue
# Check if bundle is mounted
local bundle_name=$(basename "$bundle") local bundle_name=$(basename "$bundle")
local mounted_path=$(hdiutil info 2> /dev/null | grep -A 5 "image-path.*$bundle_name" | grep "/Volumes/" | awk '{print $1}' | head -1 || echo "") local mounted_path=$(hdiutil info 2> /dev/null | grep -A 5 "image-path.*$bundle_name" | grep "/Volumes/" | awk '{print $1}' | head -1 || echo "")
if [[ -n "$mounted_path" && -d "$mounted_path" ]]; then if [[ -n "$mounted_path" && -d "$mounted_path" ]]; then
while IFS= read -r inprogress_file; do while IFS= read -r inprogress_file; do
[[ -d "$inprogress_file" ]] || continue [[ -d "$inprogress_file" ]] || continue
# Only delete old failed backups (safety window)
local file_mtime=$(get_file_mtime "$inprogress_file") local file_mtime=$(get_file_mtime "$inprogress_file")
local current_time=$(date +%s) local current_time=$(date +%s)
local hours_old=$(((current_time - file_mtime) / 3600)) local hours_old=$(((current_time - file_mtime) / 3600))
if [[ $hours_old -lt $MOLE_TM_BACKUP_SAFE_HOURS ]]; then if [[ $hours_old -lt $MOLE_TM_BACKUP_SAFE_HOURS ]]; then
continue continue
fi fi
local size_kb=$(get_path_size_kb "$inprogress_file") local size_kb=$(get_path_size_kb "$inprogress_file")
[[ "$size_kb" -le 0 ]] && continue [[ "$size_kb" -le 0 ]] && continue
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
spinner_active=false
fi
local backup_name=$(basename "$inprogress_file") local backup_name=$(basename "$inprogress_file")
local size_human=$(bytes_to_human "$((size_kb * 1024))") local size_human=$(bytes_to_human "$((size_kb * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${NC} Failed APFS backup in $bundle_name: $backup_name ${YELLOW}($size_human dry)${NC}" echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete APFS backup in $bundle_name: $backup_name ${YELLOW}($size_human dry)${NC}"
((tm_cleaned++)) ((tm_cleaned++))
note_activity note_activity
continue continue
fi fi
# Real deletion
if ! command -v tmutil > /dev/null 2>&1; then if ! command -v tmutil > /dev/null 2>&1; then
continue continue
fi fi
if tmutil delete "$inprogress_file" 2> /dev/null; then if tmutil delete "$inprogress_file" 2> /dev/null; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Failed APFS backup in $bundle_name: $backup_name ${GREEN}($size_human)${NC}" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name ${GREEN}($size_human)${NC}"
((tm_cleaned++)) ((tm_cleaned++))
((files_cleaned++)) ((files_cleaned++))
((total_size_cleaned += size_kb)) ((total_size_cleaned += size_kb))
@@ -251,61 +235,86 @@ clean_time_machine_failed_backups() {
done < <(run_with_timeout 15 find "$mounted_path" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) done < <(run_with_timeout 15 find "$mounted_path" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true)
fi fi
done done
if [[ -t 1 ]]; then stop_inline_spinner; fi
done done
if [[ "$spinner_active" == "true" ]]; then
stop_section_spinner
fi
if [[ $tm_cleaned -eq 0 ]]; then if [[ $tm_cleaned -eq 0 ]]; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No failed Time Machine backups found" echo -e " ${GREEN}${ICON_SUCCESS}${NC} No incomplete backups found"
fi fi
} }
# Local APFS snapshots (keep the most recent).
# Clean local APFS snapshots (older than 24h)
clean_local_snapshots() { clean_local_snapshots() {
# Check if tmutil is available
if ! command -v tmutil > /dev/null 2>&1; then if ! command -v tmutil > /dev/null 2>&1; then
return 0 return 0
fi fi
start_section_spinner "Checking local snapshots..."
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking local snapshots..."
fi
# Check for local snapshots
local snapshot_list local snapshot_list
snapshot_list=$(tmutil listlocalsnapshots / 2> /dev/null) snapshot_list=$(tmutil listlocalsnapshots / 2> /dev/null)
stop_section_spinner
if [[ -t 1 ]]; then stop_inline_spinner; fi
[[ -z "$snapshot_list" ]] && return 0 [[ -z "$snapshot_list" ]] && return 0
# Parse and clean snapshots
local cleaned_count=0 local cleaned_count=0
local total_cleaned_size=0 # Estimation not possible without thin local total_cleaned_size=0 # Estimation not possible without thin
local newest_ts=0
# Get current time local newest_name=""
local current_ts=$(date +%s) local -a snapshots=()
local one_day_ago=$((current_ts - 86400))
while IFS= read -r line; do while IFS= read -r line; do
# Format: com.apple.TimeMachine.2023-10-25-120000
if [[ "$line" =~ com\.apple\.TimeMachine\.([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{6}) ]]; then if [[ "$line" =~ com\.apple\.TimeMachine\.([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{6}) ]]; then
local snap_name="${BASH_REMATCH[0]}"
snapshots+=("$snap_name")
local date_str="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]:0:2}:${BASH_REMATCH[4]:2:2}:${BASH_REMATCH[4]:4:2}" local date_str="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]:0:2}:${BASH_REMATCH[4]:2:2}:${BASH_REMATCH[4]:4:2}"
local snap_ts=$(date -j -f "%Y-%m-%d %H:%M:%S" "$date_str" "+%s" 2> /dev/null || echo "0") local snap_ts=$(date -j -f "%Y-%m-%d %H:%M:%S" "$date_str" "+%s" 2> /dev/null || echo "0")
# Skip if parsing failed
[[ "$snap_ts" == "0" ]] && continue [[ "$snap_ts" == "0" ]] && continue
if [[ "$snap_ts" -gt "$newest_ts" ]]; then
newest_ts="$snap_ts"
newest_name="$snap_name"
fi
fi
done <<< "$snapshot_list"
# If snapshot is older than 24 hours [[ ${#snapshots[@]} -eq 0 ]] && return 0
if [[ $snap_ts -lt $one_day_ago ]]; then [[ -z "$newest_name" ]] && return 0
local snap_name="${BASH_REMATCH[0]}"
local deletable_count=$((${#snapshots[@]} - 1))
[[ $deletable_count -le 0 ]] && return 0
if [[ "$DRY_RUN" != "true" ]]; then
if [[ ! -t 0 ]]; then
echo -e " ${YELLOW}!${NC} ${#snapshots[@]} local snapshot(s) found, skipping non-interactive mode"
echo -e " ${YELLOW}${ICON_WARNING}${NC} ${GRAY}Tip: Snapshots may cause Disk Utility to show different 'Available' values${NC}"
return 0
fi
echo -e " ${YELLOW}!${NC} Time Machine local snapshots found"
echo -e " ${GRAY}macOS can recreate them if needed.${NC}"
echo -e " ${GRAY}The most recent snapshot will be kept.${NC}"
echo -ne " ${PURPLE}${ICON_ARROW}${NC} Remove all local snapshots except the most recent one? ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: "
local choice
if type read_key > /dev/null 2>&1; then
choice=$(read_key)
else
IFS= read -r -s -n 1 choice || choice=""
if [[ -z "$choice" || "$choice" == $'\n' || "$choice" == $'\r' ]]; then
choice="ENTER"
fi
fi
if [[ "$choice" == "ENTER" ]]; then
printf "\r\033[K" # Clear the prompt line
else
echo -e " ${GRAY}Skipped${NC}"
return 0
fi
fi
local snap_name
for snap_name in "${snapshots[@]}"; do
if [[ "$snap_name" =~ com\.apple\.TimeMachine\.([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{6}) ]]; then
if [[ "${BASH_REMATCH[0]}" != "$newest_name" ]]; then
if [[ "$DRY_RUN" == "true" ]]; then if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${NC} Old local snapshot: $snap_name ${YELLOW}(dry)${NC}" echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Local snapshot: $snap_name ${YELLOW}dry-run${NC}"
((cleaned_count++)) ((cleaned_count++))
note_activity note_activity
else else
# Secure removal if sudo tmutil deletelocalsnapshots "${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]}-${BASH_REMATCH[4]}" > /dev/null 2>&1; then
if safe_sudo tmutil deletelocalsnapshots "${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]}-${BASH_REMATCH[4]}" > /dev/null 2>&1; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed snapshot: $snap_name" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed snapshot: $snap_name"
((cleaned_count++)) ((cleaned_count++))
note_activity note_activity
@@ -315,9 +324,8 @@ clean_local_snapshots() {
fi fi
fi fi
fi fi
done <<< "$snapshot_list" done
if [[ $cleaned_count -gt 0 && "$DRY_RUN" != "true" ]]; then if [[ $cleaned_count -gt 0 && "$DRY_RUN" != "true" ]]; then
log_success "Cleaned $cleaned_count old local snapshots" log_success "Cleaned $cleaned_count local snapshots, kept latest"
fi fi
} }

View File

@@ -1,160 +1,92 @@
#!/bin/bash #!/bin/bash
# User Data Cleanup Module # User Data Cleanup Module
set -euo pipefail set -euo pipefail
# Clean user essentials (caches, logs, trash, crash reports)
clean_user_essentials() { clean_user_essentials() {
start_section_spinner "Scanning caches..."
safe_clean ~/Library/Caches/* "User app cache" safe_clean ~/Library/Caches/* "User app cache"
stop_section_spinner
safe_clean ~/Library/Logs/* "User app logs" safe_clean ~/Library/Logs/* "User app logs"
safe_clean ~/.Trash/* "Trash" if is_path_whitelisted "$HOME/.Trash"; then
# Empty trash on mounted volumes
if [[ -d "/Volumes" && "$DRY_RUN" != "true" ]]; then
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning external volumes..."
fi
for volume in /Volumes/*; do
[[ -d "$volume" && -d "$volume/.Trashes" && -w "$volume" ]] || continue
# Skip network volumes
local fs_type=$(command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}')
case "$fs_type" in
nfs | smbfs | afpfs | cifs | webdav) continue ;;
esac
# Verify volume is mounted and not a symlink
mount | grep -q "on $volume " || continue
[[ -L "$volume/.Trashes" ]] && continue
# Safely iterate and remove each item
while IFS= read -r -d '' item; do
safe_remove "$item" true || true
done < <(command find "$volume/.Trashes" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
done
if [[ -t 1 ]]; then stop_inline_spinner; fi
fi
safe_clean ~/Library/DiagnosticReports/* "Diagnostic reports"
safe_clean ~/Library/Caches/com.apple.QuickLook.thumbnailcache "QuickLook thumbnails"
safe_clean ~/Library/Caches/Quick\ Look/* "QuickLook cache"
safe_clean ~/Library/Caches/com.apple.iconservices* "Icon services cache"
safe_clean ~/Library/Caches/CloudKit/* "CloudKit cache"
# Clean incomplete downloads
safe_clean ~/Downloads/*.download "Incomplete downloads (Safari)"
safe_clean ~/Downloads/*.crdownload "Incomplete downloads (Chrome)"
safe_clean ~/Downloads/*.part "Incomplete downloads (partial)"
# Additional user-level caches
safe_clean ~/Library/Autosave\ Information/* "Autosave information"
safe_clean ~/Library/IdentityCaches/* "Identity caches"
safe_clean ~/Library/Suggestions/* "Suggestions cache (Siri)"
safe_clean ~/Library/Calendars/Calendar\ Cache "Calendar cache"
safe_clean ~/Library/Application\ Support/AddressBook/Sources/*/Photos.cache "Address Book photo cache"
}
# Clean Finder metadata (.DS_Store files)
clean_finder_metadata() {
if [[ "$PROTECT_FINDER_METADATA" == "true" ]]; then
note_activity note_activity
echo -e " ${GRAY}${ICON_SUCCESS}${NC} Finder metadata (whitelisted)" echo -e " ${GREEN}${ICON_EMPTY}${NC} Trash · whitelist protected"
else else
clean_ds_store_tree "$HOME" "Home directory (.DS_Store)" safe_clean ~/.Trash/* "Trash"
if [[ -d "/Volumes" ]]; then
for volume in /Volumes/*; do
[[ -d "$volume" && -w "$volume" ]] || continue
local fs_type=""
fs_type=$(command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}')
case "$fs_type" in
nfs | smbfs | afpfs | cifs | webdav) continue ;;
esac
clean_ds_store_tree "$volume" "$(basename "$volume") volume (.DS_Store)"
done
fi
fi fi
} }
# Clean macOS system caches # Remove old Google Chrome versions while keeping Current.
clean_macos_system_caches() { clean_chrome_old_versions() {
safe_clean ~/Library/Saved\ Application\ State/* "Saved application states" local -a app_paths=(
safe_clean ~/Library/Caches/com.apple.spotlight "Spotlight cache" "/Applications/Google Chrome.app"
"$HOME/Applications/Google Chrome.app"
)
# MOVED: Spotlight cache cleanup moved to optimize command # Use -f to match Chrome Helper processes as well
if pgrep -f "Google Chrome" > /dev/null 2>&1; then
safe_clean ~/Library/Caches/com.apple.photoanalysisd "Photo analysis cache" echo -e " ${YELLOW}${ICON_WARNING}${NC} Google Chrome running · old versions cleanup skipped"
safe_clean ~/Library/Caches/com.apple.akd "Apple ID cache" return 0
safe_clean ~/Library/Caches/com.apple.Safari/Webpage\ Previews/* "Safari webpage previews"
safe_clean ~/Library/Application\ Support/CloudDocs/session/db/* "iCloud session cache"
safe_clean ~/Library/Caches/com.apple.Safari/fsCachedData/* "Safari cached data"
safe_clean ~/Library/Caches/com.apple.WebKit.WebContent/* "WebKit content cache"
safe_clean ~/Library/Caches/com.apple.WebKit.Networking/* "WebKit network cache"
}
# Clean sandboxed app caches
clean_sandboxed_app_caches() {
safe_clean ~/Library/Containers/com.apple.wallpaper.agent/Data/Library/Caches/* "Wallpaper agent cache"
safe_clean ~/Library/Containers/com.apple.mediaanalysisd/Data/Library/Caches/* "Media analysis cache"
safe_clean ~/Library/Containers/com.apple.AppStore/Data/Library/Caches/* "App Store cache"
safe_clean ~/Library/Containers/com.apple.configurator.xpc.InternetService/Data/tmp/* "Apple Configurator temp files"
# Clean sandboxed app caches - iterate quietly to avoid UI flashing
local containers_dir="$HOME/Library/Containers"
[[ ! -d "$containers_dir" ]] && return 0
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning sandboxed apps..."
fi fi
local total_size=0
local cleaned_count=0 local cleaned_count=0
local found_any=false local total_size=0
local cleaned_any=false
for container_dir in "$containers_dir"/*; do for app_path in "${app_paths[@]}"; do
[[ -d "$container_dir" ]] || continue [[ -d "$app_path" ]] || continue
# Extract bundle ID and check protection status early local versions_dir="$app_path/Contents/Frameworks/Google Chrome Framework.framework/Versions"
local bundle_id=$(basename "$container_dir") [[ -d "$versions_dir" ]] || continue
if should_protect_data "$bundle_id"; then
local current_link="$versions_dir/Current"
[[ -L "$current_link" ]] || continue
local current_version
current_version=$(readlink "$current_link" 2> /dev/null || true)
current_version="${current_version##*/}"
[[ -n "$current_version" ]] || continue
local -a old_versions=()
local dir name
for dir in "$versions_dir"/*; do
[[ -d "$dir" ]] || continue
name=$(basename "$dir")
[[ "$name" == "Current" ]] && continue
[[ "$name" == "$current_version" ]] && continue
if is_path_whitelisted "$dir"; then
continue
fi
old_versions+=("$dir")
done
if [[ ${#old_versions[@]} -eq 0 ]]; then
continue continue
fi fi
local cache_dir="$container_dir/Data/Library/Caches" for dir in "${old_versions[@]}"; do
# Check if dir exists and has content local size_kb
if [[ -d "$cache_dir" ]]; then size_kb=$(get_path_size_kb "$dir" || echo 0)
# Fast check if empty (avoid expensive size calc on empty dirs) size_kb="${size_kb:-0}"
if [[ -n "$(ls -A "$cache_dir" 2> /dev/null)" ]]; then total_size=$((total_size + size_kb))
# Get size ((cleaned_count++))
local size=$(get_path_size_kb "$cache_dir") cleaned_any=true
((total_size += size)) if [[ "$DRY_RUN" != "true" ]]; then
found_any=true if has_sudo_session; then
((cleaned_count++)) safe_sudo_remove "$dir" > /dev/null 2>&1 || true
else
if [[ "$DRY_RUN" != "true" ]]; then safe_remove "$dir" true > /dev/null 2>&1 || true
# Clean contents safely
# We know this is a user cache path, so rm -rf is acceptable here
# provided we keep the Cache directory itself
for item in "${cache_dir:?}"/*; do
safe_remove "$item" true || true
done
fi fi
fi fi
fi done
done done
if [[ -t 1 ]]; then stop_inline_spinner; fi if [[ "$cleaned_any" == "true" ]]; then
local size_human
if [[ "$found_any" == "true" ]]; then size_human=$(bytes_to_human "$((total_size * 1024))")
local size_human=$(bytes_to_human "$((total_size * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${NC} Sandboxed app caches ${YELLOW}($size_human dry)${NC}" echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Chrome old versions ${YELLOW}(${cleaned_count} dirs, $size_human dry)${NC}"
else else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches ${GREEN}($size_human)${NC}" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Chrome old versions ${GREEN}(${cleaned_count} dirs, $size_human)${NC}"
fi fi
# Update global counters
((files_cleaned += cleaned_count)) ((files_cleaned += cleaned_count))
((total_size_cleaned += total_size)) ((total_size_cleaned += total_size))
((total_items++)) ((total_items++))
@@ -162,16 +94,305 @@ clean_sandboxed_app_caches() {
fi fi
} }
# Clean browser caches (Safari, Chrome, Edge, Firefox, etc.) # Remove old Microsoft Edge versions while keeping Current.
clean_browsers() { clean_edge_old_versions() {
safe_clean ~/Library/Caches/com.apple.Safari/* "Safari cache" local -a app_paths=(
"/Applications/Microsoft Edge.app"
"$HOME/Applications/Microsoft Edge.app"
)
# Chrome/Chromium # Use -f to match Edge Helper processes as well
if pgrep -f "Microsoft Edge" > /dev/null 2>&1; then
echo -e " ${YELLOW}${ICON_WARNING}${NC} Microsoft Edge running · old versions cleanup skipped"
return 0
fi
local cleaned_count=0
local total_size=0
local cleaned_any=false
for app_path in "${app_paths[@]}"; do
[[ -d "$app_path" ]] || continue
local versions_dir="$app_path/Contents/Frameworks/Microsoft Edge Framework.framework/Versions"
[[ -d "$versions_dir" ]] || continue
local current_link="$versions_dir/Current"
[[ -L "$current_link" ]] || continue
local current_version
current_version=$(readlink "$current_link" 2> /dev/null || true)
current_version="${current_version##*/}"
[[ -n "$current_version" ]] || continue
local -a old_versions=()
local dir name
for dir in "$versions_dir"/*; do
[[ -d "$dir" ]] || continue
name=$(basename "$dir")
[[ "$name" == "Current" ]] && continue
[[ "$name" == "$current_version" ]] && continue
if is_path_whitelisted "$dir"; then
continue
fi
old_versions+=("$dir")
done
if [[ ${#old_versions[@]} -eq 0 ]]; then
continue
fi
for dir in "${old_versions[@]}"; do
local size_kb
size_kb=$(get_path_size_kb "$dir" || echo 0)
size_kb="${size_kb:-0}"
total_size=$((total_size + size_kb))
((cleaned_count++))
cleaned_any=true
if [[ "$DRY_RUN" != "true" ]]; then
if has_sudo_session; then
safe_sudo_remove "$dir" > /dev/null 2>&1 || true
else
safe_remove "$dir" true > /dev/null 2>&1 || true
fi
fi
done
done
if [[ "$cleaned_any" == "true" ]]; then
local size_human
size_human=$(bytes_to_human "$((total_size * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge old versions ${YELLOW}(${cleaned_count} dirs, $size_human dry)${NC}"
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge old versions ${GREEN}(${cleaned_count} dirs, $size_human)${NC}"
fi
((files_cleaned += cleaned_count))
((total_size_cleaned += total_size))
((total_items++))
note_activity
fi
}
scan_external_volumes() {
[[ -d "/Volumes" ]] || return 0
local -a candidate_volumes=()
local -a network_volumes=()
for volume in /Volumes/*; do
[[ -d "$volume" && -w "$volume" && ! -L "$volume" ]] || continue
[[ "$volume" == "/" || "$volume" == "/Volumes/Macintosh HD" ]] && continue
local protocol=""
protocol=$(run_with_timeout 1 command diskutil info "$volume" 2> /dev/null | grep -i "Protocol:" | awk '{print $2}' || echo "")
case "$protocol" in
SMB | NFS | AFP | CIFS | WebDAV)
network_volumes+=("$volume")
continue
;;
esac
local fs_type=""
fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "")
case "$fs_type" in
nfs | smbfs | afpfs | cifs | webdav)
network_volumes+=("$volume")
continue
;;
esac
candidate_volumes+=("$volume")
done
local volume_count=${#candidate_volumes[@]}
local network_count=${#network_volumes[@]}
if [[ $volume_count -eq 0 ]]; then
if [[ $network_count -gt 0 ]]; then
echo -e " ${GRAY}${ICON_LIST}${NC} External volumes (${network_count} network volume(s) skipped)"
note_activity
fi
return 0
fi
start_section_spinner "Scanning $volume_count external volume(s)..."
for volume in "${candidate_volumes[@]}"; do
[[ -d "$volume" && -r "$volume" ]] || continue
local volume_trash="$volume/.Trashes"
if [[ -d "$volume_trash" && "$DRY_RUN" != "true" ]] && ! is_path_whitelisted "$volume_trash"; then
while IFS= read -r -d '' item; do
safe_remove "$item" true || true
done < <(command find "$volume_trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
fi
if [[ "$PROTECT_FINDER_METADATA" != "true" ]]; then
clean_ds_store_tree "$volume" "$(basename "$volume") volume (.DS_Store)"
fi
done
stop_section_spinner
}
# Finder metadata (.DS_Store).
clean_finder_metadata() {
stop_section_spinner
if [[ "$PROTECT_FINDER_METADATA" == "true" ]]; then
note_activity
echo -e " ${GREEN}${ICON_EMPTY}${NC} Finder metadata · whitelist protected"
return
fi
clean_ds_store_tree "$HOME" "Home directory (.DS_Store)"
}
# macOS system caches and user-level leftovers.
clean_macos_system_caches() {
stop_section_spinner
# safe_clean already checks protected paths.
safe_clean ~/Library/Saved\ Application\ State/* "Saved application states" || true
safe_clean ~/Library/Caches/com.apple.photoanalysisd "Photo analysis cache" || true
safe_clean ~/Library/Caches/com.apple.akd "Apple ID cache" || true
safe_clean ~/Library/Caches/com.apple.WebKit.Networking/* "WebKit network cache" || true
safe_clean ~/Library/DiagnosticReports/* "Diagnostic reports" || true
safe_clean ~/Library/Caches/com.apple.QuickLook.thumbnailcache "QuickLook thumbnails" || true
safe_clean ~/Library/Caches/Quick\ Look/* "QuickLook cache" || true
safe_clean ~/Library/Caches/com.apple.iconservices* "Icon services cache" || true
safe_clean ~/Downloads/*.download "Safari incomplete downloads" || true
safe_clean ~/Downloads/*.crdownload "Chrome incomplete downloads" || true
safe_clean ~/Downloads/*.part "Partial incomplete downloads" || true
safe_clean ~/Library/Autosave\ Information/* "Autosave information" || true
safe_clean ~/Library/IdentityCaches/* "Identity caches" || true
safe_clean ~/Library/Suggestions/* "Siri suggestions cache" || true
safe_clean ~/Library/Calendars/Calendar\ Cache "Calendar cache" || true
safe_clean ~/Library/Application\ Support/AddressBook/Sources/*/Photos.cache "Address Book photo cache" || true
}
clean_recent_items() {
stop_section_spinner
local shared_dir="$HOME/Library/Application Support/com.apple.sharedfilelist"
local -a recent_lists=(
"$shared_dir/com.apple.LSSharedFileList.RecentApplications.sfl2"
"$shared_dir/com.apple.LSSharedFileList.RecentDocuments.sfl2"
"$shared_dir/com.apple.LSSharedFileList.RecentServers.sfl2"
"$shared_dir/com.apple.LSSharedFileList.RecentHosts.sfl2"
"$shared_dir/com.apple.LSSharedFileList.RecentApplications.sfl"
"$shared_dir/com.apple.LSSharedFileList.RecentDocuments.sfl"
"$shared_dir/com.apple.LSSharedFileList.RecentServers.sfl"
"$shared_dir/com.apple.LSSharedFileList.RecentHosts.sfl"
)
if [[ -d "$shared_dir" ]]; then
for sfl_file in "${recent_lists[@]}"; do
[[ -e "$sfl_file" ]] && safe_clean "$sfl_file" "Recent items list" || true
done
fi
safe_clean ~/Library/Preferences/com.apple.recentitems.plist "Recent items preferences" || true
}
clean_mail_downloads() {
stop_section_spinner
local mail_age_days=${MOLE_MAIL_AGE_DAYS:-30}
if ! [[ "$mail_age_days" =~ ^[0-9]+$ ]]; then
mail_age_days=30
fi
local -a mail_dirs=(
"$HOME/Library/Mail Downloads"
"$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads"
)
local count=0
local cleaned_kb=0
for target_path in "${mail_dirs[@]}"; do
if [[ -d "$target_path" ]]; then
local dir_size_kb=0
dir_size_kb=$(get_path_size_kb "$target_path")
if ! [[ "$dir_size_kb" =~ ^[0-9]+$ ]]; then
dir_size_kb=0
fi
local min_kb="${MOLE_MAIL_DOWNLOADS_MIN_KB:-5120}"
if ! [[ "$min_kb" =~ ^[0-9]+$ ]]; then
min_kb=5120
fi
if [[ "$dir_size_kb" -lt "$min_kb" ]]; then
continue
fi
while IFS= read -r -d '' file_path; do
if [[ -f "$file_path" ]]; then
local file_size_kb=$(get_path_size_kb "$file_path")
if safe_remove "$file_path" true; then
((count++))
((cleaned_kb += file_size_kb))
fi
fi
done < <(command find "$target_path" -type f -mtime +"$mail_age_days" -print0 2> /dev/null || true)
fi
done
if [[ $count -gt 0 ]]; then
local cleaned_mb=$(echo "$cleaned_kb" | awk '{printf "%.1f", $1/1024}' || echo "0.0")
echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $count mail attachments (~${cleaned_mb}MB)"
note_activity
fi
}
# Sandboxed app caches.
clean_sandboxed_app_caches() {
stop_section_spinner
safe_clean ~/Library/Containers/com.apple.wallpaper.agent/Data/Library/Caches/* "Wallpaper agent cache"
safe_clean ~/Library/Containers/com.apple.mediaanalysisd/Data/Library/Caches/* "Media analysis cache"
safe_clean ~/Library/Containers/com.apple.AppStore/Data/Library/Caches/* "App Store cache"
safe_clean ~/Library/Containers/com.apple.configurator.xpc.InternetService/Data/tmp/* "Apple Configurator temp files"
local containers_dir="$HOME/Library/Containers"
[[ ! -d "$containers_dir" ]] && return 0
start_section_spinner "Scanning sandboxed apps..."
local total_size=0
local cleaned_count=0
local found_any=false
# Use nullglob to avoid literal globs.
local _ng_state
_ng_state=$(shopt -p nullglob || true)
shopt -s nullglob
for container_dir in "$containers_dir"/*; do
process_container_cache "$container_dir"
done
eval "$_ng_state"
stop_section_spinner
if [[ "$found_any" == "true" ]]; then
local size_human=$(bytes_to_human "$((total_size * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches ${YELLOW}($size_human dry)${NC}"
else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches ${GREEN}($size_human)${NC}"
fi
((files_cleaned += cleaned_count))
((total_size_cleaned += total_size))
((total_items++))
note_activity
fi
}
# Process a single container cache directory.
process_container_cache() {
local container_dir="$1"
[[ -d "$container_dir" ]] || return 0
local bundle_id=$(basename "$container_dir")
if is_critical_system_component "$bundle_id"; then
return 0
fi
if should_protect_data "$bundle_id" || should_protect_data "$(echo "$bundle_id" | tr '[:upper:]' '[:lower:]')"; then
return 0
fi
local cache_dir="$container_dir/Data/Library/Caches"
[[ -d "$cache_dir" ]] || return 0
# Fast non-empty check.
if find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then
local size=$(get_path_size_kb "$cache_dir")
((total_size += size))
found_any=true
((cleaned_count++))
if [[ "$DRY_RUN" != "true" ]]; then
# Clean contents safely with local nullglob.
local _ng_state
_ng_state=$(shopt -p nullglob || true)
shopt -s nullglob
for item in "$cache_dir"/*; do
[[ -e "$item" ]] || continue
safe_remove "$item" true || true
done
eval "$_ng_state"
fi
fi
}
# Browser caches (Safari/Chrome/Edge/Firefox).
clean_browsers() {
stop_section_spinner
safe_clean ~/Library/Caches/com.apple.Safari/* "Safari cache"
# Chrome/Chromium.
safe_clean ~/Library/Caches/Google/Chrome/* "Chrome cache" safe_clean ~/Library/Caches/Google/Chrome/* "Chrome cache"
safe_clean ~/Library/Application\ Support/Google/Chrome/*/Application\ Cache/* "Chrome app cache" safe_clean ~/Library/Application\ Support/Google/Chrome/*/Application\ Cache/* "Chrome app cache"
safe_clean ~/Library/Application\ Support/Google/Chrome/*/GPUCache/* "Chrome GPU cache" safe_clean ~/Library/Application\ Support/Google/Chrome/*/GPUCache/* "Chrome GPU cache"
safe_clean ~/Library/Caches/Chromium/* "Chromium cache" safe_clean ~/Library/Caches/Chromium/* "Chromium cache"
safe_clean ~/Library/Caches/com.microsoft.edgemac/* "Edge cache" safe_clean ~/Library/Caches/com.microsoft.edgemac/* "Edge cache"
safe_clean ~/Library/Caches/company.thebrowser.Browser/* "Arc cache" safe_clean ~/Library/Caches/company.thebrowser.Browser/* "Arc cache"
safe_clean ~/Library/Caches/company.thebrowser.dia/* "Dia cache" safe_clean ~/Library/Caches/company.thebrowser.dia/* "Dia cache"
@@ -183,13 +404,12 @@ clean_browsers() {
safe_clean ~/Library/Caches/com.kagi.kagimacOS/* "Orion cache" safe_clean ~/Library/Caches/com.kagi.kagimacOS/* "Orion cache"
safe_clean ~/Library/Caches/zen/* "Zen cache" safe_clean ~/Library/Caches/zen/* "Zen cache"
safe_clean ~/Library/Application\ Support/Firefox/Profiles/*/cache2/* "Firefox profile cache" safe_clean ~/Library/Application\ Support/Firefox/Profiles/*/cache2/* "Firefox profile cache"
clean_chrome_old_versions
# DISABLED: Service Worker CacheStorage scanning (find can hang on large browser profiles) clean_edge_old_versions
# Browser caches are already cleaned by the safe_clean calls above
} }
# Cloud storage caches.
# Clean cloud storage app caches
clean_cloud_storage() { clean_cloud_storage() {
stop_section_spinner
safe_clean ~/Library/Caches/com.dropbox.* "Dropbox cache" safe_clean ~/Library/Caches/com.dropbox.* "Dropbox cache"
safe_clean ~/Library/Caches/com.getdropbox.dropbox "Dropbox cache" safe_clean ~/Library/Caches/com.getdropbox.dropbox "Dropbox cache"
safe_clean ~/Library/Caches/com.google.GoogleDrive "Google Drive cache" safe_clean ~/Library/Caches/com.google.GoogleDrive "Google Drive cache"
@@ -198,9 +418,9 @@ clean_cloud_storage() {
safe_clean ~/Library/Caches/com.box.desktop "Box cache" safe_clean ~/Library/Caches/com.box.desktop "Box cache"
safe_clean ~/Library/Caches/com.microsoft.OneDrive "OneDrive cache" safe_clean ~/Library/Caches/com.microsoft.OneDrive "OneDrive cache"
} }
# Office app caches.
# Clean office application caches
clean_office_applications() { clean_office_applications() {
stop_section_spinner
safe_clean ~/Library/Caches/com.microsoft.Word "Microsoft Word cache" safe_clean ~/Library/Caches/com.microsoft.Word "Microsoft Word cache"
safe_clean ~/Library/Caches/com.microsoft.Excel "Microsoft Excel cache" safe_clean ~/Library/Caches/com.microsoft.Excel "Microsoft Excel cache"
safe_clean ~/Library/Caches/com.microsoft.Powerpoint "Microsoft PowerPoint cache" safe_clean ~/Library/Caches/com.microsoft.Powerpoint "Microsoft PowerPoint cache"
@@ -210,117 +430,107 @@ clean_office_applications() {
safe_clean ~/Library/Caches/org.mozilla.thunderbird/* "Thunderbird cache" safe_clean ~/Library/Caches/org.mozilla.thunderbird/* "Thunderbird cache"
safe_clean ~/Library/Caches/com.apple.mail/* "Apple Mail cache" safe_clean ~/Library/Caches/com.apple.mail/* "Apple Mail cache"
} }
# Virtualization caches.
# Clean virtualization tools
clean_virtualization_tools() { clean_virtualization_tools() {
stop_section_spinner
safe_clean ~/Library/Caches/com.vmware.fusion "VMware Fusion cache" safe_clean ~/Library/Caches/com.vmware.fusion "VMware Fusion cache"
safe_clean ~/Library/Caches/com.parallels.* "Parallels cache" safe_clean ~/Library/Caches/com.parallels.* "Parallels cache"
safe_clean ~/VirtualBox\ VMs/.cache "VirtualBox cache" safe_clean ~/VirtualBox\ VMs/.cache "VirtualBox cache"
safe_clean ~/.vagrant.d/tmp/* "Vagrant temporary files" safe_clean ~/.vagrant.d/tmp/* "Vagrant temporary files"
} }
# Application Support logs/caches.
# Clean Application Support logs and caches
clean_application_support_logs() { clean_application_support_logs() {
stop_section_spinner
if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" > /dev/null 2>&1; then if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" > /dev/null 2>&1; then
note_activity note_activity
echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped: No permission to access Application Support" echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped: No permission to access Application Support"
return 0 return 0
fi fi
start_section_spinner "Scanning Application Support..."
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning Application Support..."
fi
local total_size=0 local total_size=0
local cleaned_count=0 local cleaned_count=0
local found_any=false local found_any=false
# Enable nullglob for safe globbing.
# Clean log directories and cache patterns local _ng_state
_ng_state=$(shopt -p nullglob || true)
shopt -s nullglob
for app_dir in ~/Library/Application\ Support/*; do for app_dir in ~/Library/Application\ Support/*; do
[[ -d "$app_dir" ]] || continue [[ -d "$app_dir" ]] || continue
local app_name=$(basename "$app_dir") local app_name=$(basename "$app_dir")
local app_name_lower=$(echo "$app_name" | tr '[:upper:]' '[:lower:]') local app_name_lower=$(echo "$app_name" | tr '[:upper:]' '[:lower:]')
local is_protected=false local is_protected=false
if should_protect_data "$app_name"; then if should_protect_data "$app_name"; then
is_protected=true is_protected=true
elif should_protect_data "$app_name_lower"; then elif should_protect_data "$app_name_lower"; then
is_protected=true is_protected=true
fi fi
if [[ "$is_protected" == "true" ]]; then if [[ "$is_protected" == "true" ]]; then
continue continue
fi fi
if is_critical_system_component "$app_name"; then
if [[ "$app_name" =~ backgroundtaskmanagement || "$app_name" =~ loginitems ]]; then
continue continue
fi fi
local -a start_candidates=("$app_dir/log" "$app_dir/logs" "$app_dir/activitylog" "$app_dir/Cache/Cache_Data" "$app_dir/Crashpad/completed") local -a start_candidates=("$app_dir/log" "$app_dir/logs" "$app_dir/activitylog" "$app_dir/Cache/Cache_Data" "$app_dir/Crashpad/completed")
for candidate in "${start_candidates[@]}"; do for candidate in "${start_candidates[@]}"; do
if [[ -d "$candidate" ]]; then if [[ -d "$candidate" ]]; then
if [[ -n "$(ls -A "$candidate" 2> /dev/null)" ]]; then if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then
local size=$(get_path_size_kb "$candidate") local size=$(get_path_size_kb "$candidate")
((total_size += size)) ((total_size += size))
((cleaned_count++)) ((cleaned_count++))
found_any=true found_any=true
if [[ "$DRY_RUN" != "true" ]]; then if [[ "$DRY_RUN" != "true" ]]; then
safe_remove "$candidate"/* true > /dev/null 2>&1 || true for item in "$candidate"/*; do
[[ -e "$item" ]] || continue
safe_remove "$item" true > /dev/null 2>&1 || true
done
fi fi
fi fi
fi fi
done done
done done
# Group Containers logs (explicit allowlist).
# Clean Group Containers logs
local known_group_containers=( local known_group_containers=(
"group.com.apple.contentdelivery" "group.com.apple.contentdelivery"
) )
for container in "${known_group_containers[@]}"; do for container in "${known_group_containers[@]}"; do
local container_path="$HOME/Library/Group Containers/$container" local container_path="$HOME/Library/Group Containers/$container"
local -a gc_candidates=("$container_path/Logs" "$container_path/Library/Logs") local -a gc_candidates=("$container_path/Logs" "$container_path/Library/Logs")
for candidate in "${gc_candidates[@]}"; do for candidate in "${gc_candidates[@]}"; do
if [[ -d "$candidate" ]]; then if [[ -d "$candidate" ]]; then
if [[ -n "$(ls -A "$candidate" 2> /dev/null)" ]]; then if find "$candidate" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then
local size=$(get_path_size_kb "$candidate") local size=$(get_path_size_kb "$candidate")
((total_size += size)) ((total_size += size))
((cleaned_count++)) ((cleaned_count++))
found_any=true found_any=true
if [[ "$DRY_RUN" != "true" ]]; then if [[ "$DRY_RUN" != "true" ]]; then
safe_remove "$candidate"/* true > /dev/null 2>&1 || true for item in "$candidate"/*; do
[[ -e "$item" ]] || continue
safe_remove "$item" true > /dev/null 2>&1 || true
done
fi fi
fi fi
fi fi
done done
done done
eval "$_ng_state"
if [[ -t 1 ]]; then stop_inline_spinner; fi stop_section_spinner
if [[ "$found_any" == "true" ]]; then if [[ "$found_any" == "true" ]]; then
local size_human=$(bytes_to_human "$((total_size * 1024))") local size_human=$(bytes_to_human "$((total_size * 1024))")
if [[ "$DRY_RUN" == "true" ]]; then if [[ "$DRY_RUN" == "true" ]]; then
echo -e " ${YELLOW}${NC} Application Support logs/caches ${YELLOW}($size_human dry)${NC}" echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches ${YELLOW}($size_human dry)${NC}"
else else
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches ${GREEN}($size_human)${NC}" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches ${GREEN}($size_human)${NC}"
fi fi
# Update global counters
((files_cleaned += cleaned_count)) ((files_cleaned += cleaned_count))
((total_size_cleaned += total_size)) ((total_size_cleaned += total_size))
((total_items++)) ((total_items++))
note_activity note_activity
fi fi
} }
# iOS device backup info.
# Check and show iOS device backup info
check_ios_device_backups() { check_ios_device_backups() {
local backup_dir="$HOME/Library/Application Support/MobileSync/Backup" local backup_dir="$HOME/Library/Application Support/MobileSync/Backup"
# Simplified check without find to avoid hanging # Simplified check without find to avoid hanging.
if [[ -d "$backup_dir" ]]; then if [[ -d "$backup_dir" ]]; then
local backup_kb=$(get_path_size_kb "$backup_dir") local backup_kb=$(get_path_size_kb "$backup_dir")
if [[ -n "${backup_kb:-}" && "$backup_kb" -gt 102400 ]]; then if [[ -n "${backup_kb:-}" && "$backup_kb" -gt 102400 ]]; then
@@ -332,16 +542,16 @@ check_ios_device_backups() {
fi fi
fi fi
fi fi
return 0
} }
# Apple Silicon specific caches (IS_M_SERIES).
# Clean Apple Silicon specific caches
# Env: IS_M_SERIES
clean_apple_silicon_caches() { clean_apple_silicon_caches() {
if [[ "$IS_M_SERIES" != "true" ]]; then if [[ "${IS_M_SERIES:-false}" != "true" ]]; then
return 0 return 0
fi fi
start_section "Apple Silicon updates"
safe_clean /Library/Apple/usr/share/rosetta/rosetta_update_bundle "Rosetta 2 cache" safe_clean /Library/Apple/usr/share/rosetta/rosetta_update_bundle "Rosetta 2 cache"
safe_clean ~/Library/Caches/com.apple.rosetta.update "Rosetta 2 user cache" safe_clean ~/Library/Caches/com.apple.rosetta.update "Rosetta 2 user cache"
safe_clean ~/Library/Caches/com.apple.amp.mediasevicesd "Apple Silicon media service cache" safe_clean ~/Library/Caches/com.apple.amp.mediasevicesd "Apple Silicon media service cache"
end_section
} }

View File

@@ -12,11 +12,9 @@ readonly MOLE_APP_PROTECTION_LOADED=1
_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[[ -z "${MOLE_BASE_LOADED:-}" ]] && source "$_MOLE_CORE_DIR/base.sh" [[ -z "${MOLE_BASE_LOADED:-}" ]] && source "$_MOLE_CORE_DIR/base.sh"
# ============================================================================ # Application Management
# App Management Functions
# ============================================================================
# System critical components that should NEVER be uninstalled # Critical system components protected from uninstallation
readonly SYSTEM_CRITICAL_BUNDLES=( readonly SYSTEM_CRITICAL_BUNDLES=(
"com.apple.*" # System essentials "com.apple.*" # System essentials
"loginwindow" "loginwindow"
@@ -24,6 +22,9 @@ readonly SYSTEM_CRITICAL_BUNDLES=(
"systempreferences" "systempreferences"
"finder" "finder"
"safari" "safari"
"com.apple.Settings*"
"com.apple.SystemSettings*"
"com.apple.controlcenter*"
"com.apple.backgroundtaskmanagement*" "com.apple.backgroundtaskmanagement*"
"com.apple.loginitems*" "com.apple.loginitems*"
"com.apple.sharedfilelist*" "com.apple.sharedfilelist*"
@@ -65,11 +66,9 @@ readonly SYSTEM_CRITICAL_BUNDLES=(
"com.apple.TextInputSwitcher" "com.apple.TextInputSwitcher"
) )
# Apps with important data/licenses - protect during cleanup but allow uninstall # Applications with sensitive data; protected during cleanup but removable
readonly DATA_PROTECTED_BUNDLES=( readonly DATA_PROTECTED_BUNDLES=(
# ============================================================================
# System Utilities & Cleanup Tools # System Utilities & Cleanup Tools
# ============================================================================
"com.nektony.*" # App Cleaner & Uninstaller "com.nektony.*" # App Cleaner & Uninstaller
"com.macpaw.*" # CleanMyMac, CleanMaster "com.macpaw.*" # CleanMyMac, CleanMaster
"com.freemacsoft.AppCleaner" # AppCleaner "com.freemacsoft.AppCleaner" # AppCleaner
@@ -79,9 +78,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.grandperspectiv.*" # GrandPerspective "com.grandperspectiv.*" # GrandPerspective
"com.binaryfruit.*" # FusionCast "com.binaryfruit.*" # FusionCast
# ============================================================================
# Password Managers & Security # Password Managers & Security
# ============================================================================
"com.1password.*" # 1Password "com.1password.*" # 1Password
"com.agilebits.*" # 1Password legacy "com.agilebits.*" # 1Password legacy
"com.lastpass.*" # LastPass "com.lastpass.*" # LastPass
@@ -92,9 +89,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.authy.*" # Authy "com.authy.*" # Authy
"com.yubico.*" # YubiKey Manager "com.yubico.*" # YubiKey Manager
# ============================================================================
# Development Tools - IDEs & Editors # Development Tools - IDEs & Editors
# ============================================================================
"com.jetbrains.*" # JetBrains IDEs (IntelliJ, DataGrip, etc.) "com.jetbrains.*" # JetBrains IDEs (IntelliJ, DataGrip, etc.)
"JetBrains*" # JetBrains Application Support folders "JetBrains*" # JetBrains Application Support folders
"com.microsoft.VSCode" # Visual Studio Code "com.microsoft.VSCode" # Visual Studio Code
@@ -109,9 +104,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"abnerworks.Typora" # Typora (Markdown editor) "abnerworks.Typora" # Typora (Markdown editor)
"com.uranusjr.macdown" # MacDown "com.uranusjr.macdown" # MacDown
# ============================================================================
# AI & LLM Tools # AI & LLM Tools
# ============================================================================
"com.todesktop.*" # Cursor (often uses generic todesktop ID) "com.todesktop.*" # Cursor (often uses generic todesktop ID)
"Cursor" # Cursor App Support "Cursor" # Cursor App Support
"com.anthropic.claude*" # Claude "com.anthropic.claude*" # Claude
@@ -133,9 +126,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.quora.poe.electron" # Poe "com.quora.poe.electron" # Poe
"chat.openai.com.*" # OpenAI web wrappers "chat.openai.com.*" # OpenAI web wrappers
# ============================================================================
# Development Tools - Database Clients # Development Tools - Database Clients
# ============================================================================
"com.sequelpro.*" # Sequel Pro "com.sequelpro.*" # Sequel Pro
"com.sequel-ace.*" # Sequel Ace "com.sequel-ace.*" # Sequel Ace
"com.tinyapp.*" # TablePlus "com.tinyapp.*" # TablePlus
@@ -148,9 +139,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.valentina-db.Valentina-Studio" # Valentina Studio "com.valentina-db.Valentina-Studio" # Valentina Studio
"com.dbvis.DbVisualizer" # DbVisualizer "com.dbvis.DbVisualizer" # DbVisualizer
# ============================================================================
# Development Tools - API & Network # Development Tools - API & Network
# ============================================================================
"com.postmanlabs.mac" # Postman "com.postmanlabs.mac" # Postman
"com.konghq.insomnia" # Insomnia "com.konghq.insomnia" # Insomnia
"com.CharlesProxy.*" # Charles Proxy "com.CharlesProxy.*" # Charles Proxy
@@ -161,16 +150,17 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.telerik.Fiddler" # Fiddler "com.telerik.Fiddler" # Fiddler
"com.usebruno.app" # Bruno (API client) "com.usebruno.app" # Bruno (API client)
# Network Proxy & VPN Tools (protect all variants) # Network Proxy & VPN Tools (pattern-based protection)
# Clash variants
"*clash*" # All Clash variants (ClashX, ClashX Pro, Clash Verge, etc) "*clash*" # All Clash variants (ClashX, ClashX Pro, Clash Verge, etc)
"*Clash*" # Capitalized variants "*Clash*" # Capitalized variants
"*clash-verge*" # Explicit Clash Verge protection
"*verge*" # Verge variants (lowercase)
"*Verge*" # Verge variants (capitalized)
"com.nssurge.surge-mac" # Surge "com.nssurge.surge-mac" # Surge
"*surge*" # Surge variants
"*Surge*" # Surge variants
"mihomo*" # Mihomo Party and variants "mihomo*" # Mihomo Party and variants
"*openvpn*" # OpenVPN Connect and variants "*openvpn*" # OpenVPN Connect and variants
"*OpenVPN*" # OpenVPN capitalized variants "*OpenVPN*" # OpenVPN capitalized variants
"net.openvpn.*" # OpenVPN bundle IDs
# Proxy Clients (Shadowsocks, V2Ray, etc) # Proxy Clients (Shadowsocks, V2Ray, etc)
"*ShadowsocksX-NG*" # ShadowsocksX-NG "*ShadowsocksX-NG*" # ShadowsocksX-NG
@@ -204,11 +194,14 @@ readonly DATA_PROTECTED_BUNDLES=(
"*windscribe*" # Windscribe "*windscribe*" # Windscribe
"*mullvad*" # Mullvad "*mullvad*" # Mullvad
"*privateinternetaccess*" # PIA "*privateinternetaccess*" # PIA
"net.openvpn.*" # OpenVPN bundle IDs
# ============================================================================ # Screensaver & Dynamic Wallpaper
"*Aerial*" # Aerial screensaver (all case variants)
"*aerial*" # Aerial lowercase
"*Fliqlo*" # Fliqlo screensaver (all case variants)
"*fliqlo*" # Fliqlo lowercase
# Development Tools - Git & Version Control # Development Tools - Git & Version Control
# ============================================================================
"com.github.GitHubDesktop" # GitHub Desktop "com.github.GitHubDesktop" # GitHub Desktop
"com.sublimemerge" # Sublime Merge "com.sublimemerge" # Sublime Merge
"com.torusknot.SourceTreeNotMAS" # SourceTree "com.torusknot.SourceTreeNotMAS" # SourceTree
@@ -218,9 +211,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.fork.Fork" # Fork "com.fork.Fork" # Fork
"com.axosoft.gitkraken" # GitKraken "com.axosoft.gitkraken" # GitKraken
# ============================================================================
# Development Tools - Terminal & Shell # Development Tools - Terminal & Shell
# ============================================================================
"com.googlecode.iterm2" # iTerm2 "com.googlecode.iterm2" # iTerm2
"net.kovidgoyal.kitty" # Kitty "net.kovidgoyal.kitty" # Kitty
"io.alacritty" # Alacritty "io.alacritty" # Alacritty
@@ -231,9 +222,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"dev.warp.Warp-Stable" # Warp "dev.warp.Warp-Stable" # Warp
"com.termius-dmg" # Termius (SSH client) "com.termius-dmg" # Termius (SSH client)
# ============================================================================
# Development Tools - Docker & Virtualization # Development Tools - Docker & Virtualization
# ============================================================================
"com.docker.docker" # Docker Desktop "com.docker.docker" # Docker Desktop
"com.getutm.UTM" # UTM "com.getutm.UTM" # UTM
"com.vmware.fusion" # VMware Fusion "com.vmware.fusion" # VMware Fusion
@@ -242,9 +231,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.vagrant.*" # Vagrant "com.vagrant.*" # Vagrant
"com.orbstack.OrbStack" # OrbStack "com.orbstack.OrbStack" # OrbStack
# ============================================================================
# System Monitoring & Performance # System Monitoring & Performance
# ============================================================================
"com.bjango.istatmenus*" # iStat Menus "com.bjango.istatmenus*" # iStat Menus
"eu.exelban.Stats" # Stats "eu.exelban.Stats" # Stats
"com.monitorcontrol.*" # MonitorControl "com.monitorcontrol.*" # MonitorControl
@@ -253,9 +240,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.activity-indicator.app" # Activity Indicator "com.activity-indicator.app" # Activity Indicator
"net.cindori.sensei" # Sensei "net.cindori.sensei" # Sensei
# ============================================================================
# Window Management & Productivity # Window Management & Productivity
# ============================================================================
"com.macitbetter.*" # BetterTouchTool, BetterSnapTool "com.macitbetter.*" # BetterTouchTool, BetterSnapTool
"com.hegenberg.*" # BetterTouchTool legacy "com.hegenberg.*" # BetterTouchTool legacy
"com.manytricks.*" # Moom, Witch, Name Mangler, Resolutionator "com.manytricks.*" # Moom, Witch, Name Mangler, Resolutionator
@@ -273,9 +258,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.gaosun.eul" # eul (system monitor) "com.gaosun.eul" # eul (system monitor)
"com.pointum.hazeover" # HazeOver "com.pointum.hazeover" # HazeOver
# ============================================================================
# Launcher & Automation # Launcher & Automation
# ============================================================================
"com.runningwithcrayons.Alfred" # Alfred "com.runningwithcrayons.Alfred" # Alfred
"com.raycast.macos" # Raycast "com.raycast.macos" # Raycast
"com.blacktree.Quicksilver" # Quicksilver "com.blacktree.Quicksilver" # Quicksilver
@@ -286,9 +269,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"org.pqrs.Karabiner-Elements" # Karabiner-Elements "org.pqrs.Karabiner-Elements" # Karabiner-Elements
"com.apple.Automator" # Automator (system, but keep user workflows) "com.apple.Automator" # Automator (system, but keep user workflows)
# ============================================================================
# Note-Taking & Documentation # Note-Taking & Documentation
# ============================================================================
"com.bear-writer.*" # Bear "com.bear-writer.*" # Bear
"com.typora.*" # Typora "com.typora.*" # Typora
"com.ulyssesapp.*" # Ulysses "com.ulyssesapp.*" # Ulysses
@@ -307,9 +288,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.reflect.ReflectApp" # Reflect "com.reflect.ReflectApp" # Reflect
"com.inkdrop.*" # Inkdrop "com.inkdrop.*" # Inkdrop
# ============================================================================
# Design & Creative Tools # Design & Creative Tools
# ============================================================================
"com.adobe.*" # Adobe Creative Suite "com.adobe.*" # Adobe Creative Suite
"com.bohemiancoding.*" # Sketch "com.bohemiancoding.*" # Sketch
"com.figma.*" # Figma "com.figma.*" # Figma
@@ -327,9 +306,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.autodesk.*" # Autodesk products "com.autodesk.*" # Autodesk products
"com.sketchup.*" # SketchUp "com.sketchup.*" # SketchUp
# ============================================================================
# Communication & Collaboration # Communication & Collaboration
# ============================================================================
"com.tencent.xinWeChat" # WeChat (Chinese users) "com.tencent.xinWeChat" # WeChat (Chinese users)
"com.tencent.qq" # QQ "com.tencent.qq" # QQ
"com.alibaba.DingTalkMac" # DingTalk "com.alibaba.DingTalkMac" # DingTalk
@@ -340,6 +317,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.microsoft.teams*" # Microsoft Teams "com.microsoft.teams*" # Microsoft Teams
"com.slack.Slack" # Slack "com.slack.Slack" # Slack
"com.hnc.Discord" # Discord "com.hnc.Discord" # Discord
"app.legcord.Legcord" # Legcord
"org.telegram.desktop" # Telegram "org.telegram.desktop" # Telegram
"ru.keepcoder.Telegram" # Telegram legacy "ru.keepcoder.Telegram" # Telegram legacy
"net.whatsapp.WhatsApp" # WhatsApp "net.whatsapp.WhatsApp" # WhatsApp
@@ -351,9 +329,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.postbox-inc.postbox" # Postbox "com.postbox-inc.postbox" # Postbox
"com.tinyspeck.slackmacgap" # Slack legacy "com.tinyspeck.slackmacgap" # Slack legacy
# ============================================================================
# Task Management & Productivity # Task Management & Productivity
# ============================================================================
"com.omnigroup.OmniFocus*" # OmniFocus "com.omnigroup.OmniFocus*" # OmniFocus
"com.culturedcode.*" # Things "com.culturedcode.*" # Things
"com.todoist.*" # Todoist "com.todoist.*" # Todoist
@@ -368,9 +344,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.notion.id" # Notion (also note-taking) "com.notion.id" # Notion (also note-taking)
"com.linear.linear" # Linear "com.linear.linear" # Linear
# ============================================================================
# File Transfer & Sync # File Transfer & Sync
# ============================================================================
"com.panic.transmit*" # Transmit (FTP/SFTP) "com.panic.transmit*" # Transmit (FTP/SFTP)
"com.binarynights.ForkLift*" # ForkLift "com.binarynights.ForkLift*" # ForkLift
"com.noodlesoft.Hazel" # Hazel "com.noodlesoft.Hazel" # Hazel
@@ -379,9 +353,34 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.apple.Xcode.CloudDocuments" # Xcode Cloud Documents "com.apple.Xcode.CloudDocuments" # Xcode Cloud Documents
"com.synology.*" # Synology apps "com.synology.*" # Synology apps
# ============================================================================ # Cloud Storage & Backup (Issue #204)
"com.dropbox.*" # Dropbox
"com.getdropbox.*" # Dropbox legacy
"*dropbox*" # Dropbox helpers/updaters
"ws.agile.*" # 1Password sync helpers
"com.backblaze.*" # Backblaze
"*backblaze*" # Backblaze helpers
"com.box.desktop*" # Box
"*box.desktop*" # Box helpers
"com.microsoft.OneDrive*" # Microsoft OneDrive
"com.microsoft.SyncReporter" # OneDrive sync reporter
"*OneDrive*" # OneDrive helpers/updaters
"com.google.GoogleDrive" # Google Drive
"com.google.keystone*" # Google updaters (Drive, Chrome, etc.)
"*GoogleDrive*" # Google Drive helpers
"com.amazon.drive" # Amazon Drive
"com.apple.bird" # iCloud Drive daemon
"com.apple.CloudDocs*" # iCloud Documents
"com.displaylink.*" # DisplayLink
"com.fujitsu.pfu.ScanSnap*" # ScanSnap
"com.citrix.*" # Citrix Workspace
"org.xquartz.*" # XQuartz
"us.zoom.updater*" # Zoom updaters
"com.DigiDNA.iMazing*" # iMazing
"com.shirtpocket.*" # SuperDuper backup
"homebrew.mxcl.*" # Homebrew services
# Screenshot & Recording # Screenshot & Recording
# ============================================================================
"com.cleanshot.*" # CleanShot X "com.cleanshot.*" # CleanShot X
"com.xnipapp.xnip" # Xnip "com.xnipapp.xnip" # Xnip
"com.reincubate.camo" # Camo "com.reincubate.camo" # Camo
@@ -395,9 +394,7 @@ readonly DATA_PROTECTED_BUNDLES=(
"com.linebreak.CloudApp" # CloudApp "com.linebreak.CloudApp" # CloudApp
"com.droplr.droplr-mac" # Droplr "com.droplr.droplr-mac" # Droplr
# ============================================================================
# Media & Entertainment # Media & Entertainment
# ============================================================================
"com.spotify.client" # Spotify "com.spotify.client" # Spotify
"com.apple.Music" # Apple Music "com.apple.Music" # Apple Music
"com.apple.podcasts" # Apple Podcasts "com.apple.podcasts" # Apple Podcasts
@@ -415,20 +412,36 @@ readonly DATA_PROTECTED_BUNDLES=(
"tv.plex.player.desktop" # Plex "tv.plex.player.desktop" # Plex
"com.netease.163music" # NetEase Music "com.netease.163music" # NetEase Music
# ============================================================================
# License Management & App Stores # License Management & App Stores
# ============================================================================
"com.paddle.Paddle*" # Paddle (license management) "com.paddle.Paddle*" # Paddle (license management)
"com.setapp.DesktopClient" # Setapp "com.setapp.DesktopClient" # Setapp
"com.devmate.*" # DevMate (license framework) "com.devmate.*" # DevMate (license framework)
"org.sparkle-project.Sparkle" # Sparkle (update framework) "org.sparkle-project.Sparkle" # Sparkle (update framework)
) )
# Centralized check for critical system components (case-insensitive)
is_critical_system_component() {
local token="$1"
[[ -z "$token" ]] && return 1
local lower
lower=$(echo "$token" | tr '[:upper:]' '[:lower:]')
case "$lower" in
*backgroundtaskmanagement* | *loginitems* | *systempreferences* | *systemsettings* | *settings* | *preferences* | *controlcenter* | *biometrickit* | *sfl* | *tcc*)
return 0
;;
*)
return 1
;;
esac
}
# Legacy function - preserved for backward compatibility # Legacy function - preserved for backward compatibility
# Use should_protect_from_uninstall() or should_protect_data() instead # Use should_protect_from_uninstall() or should_protect_data() instead
readonly PRESERVED_BUNDLE_PATTERNS=("${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}") readonly PRESERVED_BUNDLE_PATTERNS=("${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}")
# Check whether a bundle ID matches a pattern (supports globs) # Check if bundle ID matches pattern (glob support)
bundle_matches_pattern() { bundle_matches_pattern() {
local bundle_id="$1" local bundle_id="$1"
local pattern="$2" local pattern="$2"
@@ -443,7 +456,7 @@ bundle_matches_pattern() {
return 1 return 1
} }
# Check if app is a system component that should never be uninstalled # Check if application is a protected system component
should_protect_from_uninstall() { should_protect_from_uninstall() {
local bundle_id="$1" local bundle_id="$1"
for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do
@@ -454,7 +467,7 @@ should_protect_from_uninstall() {
return 1 return 1
} }
# Check if app data should be protected during cleanup (but app can be uninstalled) # Check if application data should be protected during cleanup
should_protect_data() { should_protect_data() {
local bundle_id="$1" local bundle_id="$1"
# Protect both system critical and data protected bundles during cleanup # Protect both system critical and data protected bundles during cleanup
@@ -466,290 +479,363 @@ should_protect_data() {
return 1 return 1
} }
# Find and list app-related files (consolidated from duplicates) # Check if a path is protected from deletion
# Centralized logic to protect system settings, control center, and critical apps
#
# Args: $1 - path to check
# Returns: 0 if protected, 1 if safe to delete
should_protect_path() {
local path="$1"
[[ -z "$path" ]] && return 1
local path_lower
path_lower=$(echo "$path" | tr '[:upper:]' '[:lower:]')
# 1. Keyword-based matching for system components
# Protect System Settings, Preferences, Control Center, and related XPC services
# Also protect "Settings" (used in macOS Sequoia) and savedState files
if [[ "$path_lower" =~ systemsettings || "$path_lower" =~ systempreferences || "$path_lower" =~ controlcenter ]]; then
return 0
fi
# Additional check for com.apple.Settings (macOS Sequoia System Settings)
if [[ "$path_lower" =~ com\.apple\.settings ]]; then
return 0
fi
# Protect Notes cache (search index issues)
if [[ "$path_lower" =~ com\.apple\.notes ]]; then
return 0
fi
# 2. Protect caches critical for system UI rendering
# These caches are essential for modern macOS (Sonoma/Sequoia) system UI rendering
case "$path" in
# System Settings and Control Center caches (CRITICAL - prevents blank panel bug)
*com.apple.systempreferences.cache* | *com.apple.Settings.cache* | *com.apple.controlcenter.cache*)
return 0
;;
# Finder and Dock (system essential)
*com.apple.finder.cache* | *com.apple.dock.cache*)
return 0
;;
# System XPC services and sandboxed containers
*/Library/Containers/com.apple.Settings* | */Library/Containers/com.apple.SystemSettings* | */Library/Containers/com.apple.controlcenter*)
return 0
;;
*/Library/Group\ Containers/com.apple.systempreferences* | */Library/Group\ Containers/com.apple.Settings*)
return 0
;;
# Shared file lists for System Settings (macOS Sequoia) - Issue #136
*/com.apple.sharedfilelist/*com.apple.Settings* | */com.apple.sharedfilelist/*com.apple.SystemSettings* | */com.apple.sharedfilelist/*systempreferences*)
return 0
;;
esac
# 3. Extract bundle ID from sandbox paths
# Matches: .../Library/Containers/bundle.id/...
# Matches: .../Library/Group Containers/group.id/...
if [[ "$path" =~ /Library/Containers/([^/]+) ]] || [[ "$path" =~ /Library/Group\ Containers/([^/]+) ]]; then
local bundle_id="${BASH_REMATCH[1]}"
if should_protect_data "$bundle_id"; then
return 0
fi
fi
# 4. Check for specific hardcoded critical patterns
case "$path" in
*com.apple.Settings* | *com.apple.SystemSettings* | *com.apple.controlcenter* | *com.apple.finder* | *com.apple.dock*)
return 0
;;
esac
# 5. Protect critical preference files and user data
case "$path" in
*/Library/Preferences/com.apple.dock.plist | */Library/Preferences/com.apple.finder.plist)
return 0
;;
# Bluetooth and WiFi configurations
*/ByHost/com.apple.bluetooth.* | */ByHost/com.apple.wifi.*)
return 0
;;
# iCloud Drive - protect user's cloud synced data
*/Library/Mobile\ Documents* | */Mobile\ Documents*)
return 0
;;
esac
# 6. Match full path against protected patterns
# This catches things like /Users/tw93/Library/Caches/Claude when pattern is *Claude*
for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do
if bundle_matches_pattern "$path" "$pattern"; then
return 0
fi
done
# 7. Check if the filename itself matches any protected patterns
local filename
filename=$(basename "$path")
if should_protect_data "$filename"; then
return 0
fi
return 1
}
# Check if a path is protected by whitelist patterns
# Args: $1 - path to check
# Returns: 0 if whitelisted, 1 if not
is_path_whitelisted() {
local target_path="$1"
[[ -z "$target_path" ]] && return 1
# Normalize path (remove trailing slash)
local normalized_target="${target_path%/}"
# Empty whitelist means nothing is protected
[[ ${#WHITELIST_PATTERNS[@]} -eq 0 ]] && return 1
for pattern in "${WHITELIST_PATTERNS[@]}"; do
# Pattern is already expanded/normalized in bin/clean.sh
local check_pattern="${pattern%/}"
# Check for exact match or glob pattern match
# shellcheck disable=SC2053
if [[ "$normalized_target" == "$check_pattern" ]] ||
[[ "$normalized_target" == $check_pattern ]]; then
return 0
fi
done
return 1
}
# Locate files associated with an application
find_app_files() { find_app_files() {
local bundle_id="$1" local bundle_id="$1"
local app_name="$2" local app_name="$2"
local -a files_to_clean=() local -a files_to_clean=()
# ============================================================================ # Normalize app name for matching
# User-level files (no sudo required) local nospace_name="${app_name// /}"
# ============================================================================ local underscore_name="${app_name// /_}"
# Application Support # Standard path patterns for user-level files
[[ -d ~/Library/Application\ Support/"$app_name" ]] && files_to_clean+=("$HOME/Library/Application Support/$app_name") local -a user_patterns=(
[[ -d ~/Library/Application\ Support/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Application Support/$bundle_id") "$HOME/Library/Application Support/$app_name"
"$HOME/Library/Application Support/$bundle_id"
"$HOME/Library/Caches/$bundle_id"
"$HOME/Library/Caches/$app_name"
"$HOME/Library/Logs/$app_name"
"$HOME/Library/Logs/$bundle_id"
"$HOME/Library/Application Support/CrashReporter/$app_name"
"$HOME/Library/Saved Application State/$bundle_id.savedState"
"$HOME/Library/Containers/$bundle_id"
"$HOME/Library/WebKit/$bundle_id"
"$HOME/Library/WebKit/com.apple.WebKit.WebContent/$bundle_id"
"$HOME/Library/HTTPStorages/$bundle_id"
"$HOME/Library/Cookies/$bundle_id.binarycookies"
"$HOME/Library/LaunchAgents/$bundle_id.plist"
"$HOME/Library/Application Scripts/$bundle_id"
"$HOME/Library/Services/$app_name.workflow"
"$HOME/Library/QuickLook/$app_name.qlgenerator"
"$HOME/Library/Internet Plug-Ins/$app_name.plugin"
"$HOME/Library/Audio/Plug-Ins/Components/$app_name.component"
"$HOME/Library/Audio/Plug-Ins/VST/$app_name.vst"
"$HOME/Library/Audio/Plug-Ins/VST3/$app_name.vst3"
"$HOME/Library/Audio/Plug-Ins/Digidesign/$app_name.dpm"
"$HOME/Library/PreferencePanes/$app_name.prefPane"
"$HOME/Library/Screen Savers/$app_name.saver"
"$HOME/Library/Frameworks/$app_name.framework"
"$HOME/Library/Autosave Information/$bundle_id"
"$HOME/Library/Contextual Menu Items/$app_name.plugin"
"$HOME/Library/Spotlight/$app_name.mdimporter"
"$HOME/Library/ColorPickers/$app_name.colorPicker"
"$HOME/Library/Workflows/$app_name.workflow"
"$HOME/.config/$app_name"
"$HOME/.local/share/$app_name"
"$HOME/.$app_name"
"$HOME/.$app_name"rc
)
# Sanitized App Name (remove spaces) - e.g. "Visual Studio Code" -> "VisualStudioCode" # Add sanitized name variants if unique enough
if [[ ${#app_name} -gt 3 && "$app_name" =~ [[:space:]] ]]; then if [[ ${#app_name} -gt 3 && "$app_name" =~ [[:space:]] ]]; then
local nospace_name="${app_name// /}" user_patterns+=(
[[ -d ~/Library/Application\ Support/"$nospace_name" ]] && files_to_clean+=("$HOME/Library/Application Support/$nospace_name") "$HOME/Library/Application Support/$nospace_name"
[[ -d ~/Library/Caches/"$nospace_name" ]] && files_to_clean+=("$HOME/Library/Caches/$nospace_name") "$HOME/Library/Caches/$nospace_name"
[[ -d ~/Library/Logs/"$nospace_name" ]] && files_to_clean+=("$HOME/Library/Logs/$nospace_name") "$HOME/Library/Logs/$nospace_name"
"$HOME/Library/Application Support/$underscore_name"
local underscore_name="${app_name// /_}" )
[[ -d ~/Library/Application\ Support/"$underscore_name" ]] && files_to_clean+=("$HOME/Library/Application Support/$underscore_name")
fi fi
# Caches # Process standard patterns
[[ -d ~/Library/Caches/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Caches/$bundle_id") for p in "${user_patterns[@]}"; do
[[ -d ~/Library/Caches/"$app_name" ]] && files_to_clean+=("$HOME/Library/Caches/$app_name") local expanded_path="${p/#\~/$HOME}"
# Skip if path doesn't exist
[[ ! -e "$expanded_path" ]] && continue
# Preferences # Safety check: Skip if path ends with a common directory name (indicates empty app_name/bundle_id)
[[ -f ~/Library/Preferences/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id.plist") # This prevents deletion of entire Library subdirectories when bundle_id is empty
[[ -d ~/Library/Preferences/ByHost ]] && while IFS= read -r -d '' pref; do case "$expanded_path" in
files_to_clean+=("$pref") */Library/Application\ Support | */Library/Application\ Support/ | \
done < <(find ~/Library/Preferences/ByHost \( -name "$bundle_id*.plist" \) -print0 2> /dev/null) */Library/Caches | */Library/Caches/ | \
*/Library/Logs | */Library/Logs/ | \
*/Library/Containers | */Library/Containers/ | \
*/Library/WebKit | */Library/WebKit/ | \
*/Library/HTTPStorages | */Library/HTTPStorages/ | \
*/Library/Application\ Scripts | */Library/Application\ Scripts/ | \
*/Library/Autosave\ Information | */Library/Autosave\ Information/ | \
*/Library/Group\ Containers | */Library/Group\ Containers/)
continue
;;
esac
# Logs files_to_clean+=("$expanded_path")
[[ -d ~/Library/Logs/"$app_name" ]] && files_to_clean+=("$HOME/Library/Logs/$app_name") done
[[ -d ~/Library/Logs/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Logs/$bundle_id")
# CrashReporter
[[ -d ~/Library/Application\ Support/CrashReporter/"$app_name" ]] && files_to_clean+=("$HOME/Library/Application Support/CrashReporter/$app_name")
# Saved Application State # Handle Preferences and ByHost variants (only if bundle_id is valid)
[[ -d ~/Library/Saved\ Application\ State/"$bundle_id".savedState ]] && files_to_clean+=("$HOME/Library/Saved Application State/$bundle_id.savedState") if [[ -n "$bundle_id" && "$bundle_id" != "unknown" && ${#bundle_id} -gt 3 ]]; then
[[ -f ~/Library/Preferences/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id.plist")
[[ -d ~/Library/Preferences/ByHost ]] && while IFS= read -r -d '' pref; do
files_to_clean+=("$pref")
done < <(command find ~/Library/Preferences/ByHost -maxdepth 1 \( -name "$bundle_id*.plist" \) -print0 2> /dev/null)
# Containers (sandboxed apps) # Group Containers (special handling)
[[ -d ~/Library/Containers/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Containers/$bundle_id") if [[ -d ~/Library/Group\ Containers ]]; then
while IFS= read -r -d '' container; do
files_to_clean+=("$container")
done < <(command find ~/Library/Group\ Containers -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null)
fi
fi
# Group Containers # Launch Agents by name (special handling)
[[ -d ~/Library/Group\ Containers ]] && while IFS= read -r -d '' container; do if [[ ${#app_name} -gt 3 ]] && [[ -d ~/Library/LaunchAgents ]]; then
files_to_clean+=("$container")
done < <(find ~/Library/Group\ Containers -type d \( -name "*$bundle_id*" \) -print0 2> /dev/null)
# WebKit data
[[ -d ~/Library/WebKit/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/WebKit/$bundle_id")
[[ -d ~/Library/WebKit/com.apple.WebKit.WebContent/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/WebKit/com.apple.WebKit.WebContent/$bundle_id")
# HTTP Storage
[[ -d ~/Library/HTTPStorages/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/HTTPStorages/$bundle_id")
# Cookies
[[ -f ~/Library/Cookies/"$bundle_id".binarycookies ]] && files_to_clean+=("$HOME/Library/Cookies/$bundle_id.binarycookies")
# Launch Agents (user-level)
[[ -f ~/Library/LaunchAgents/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/LaunchAgents/$bundle_id.plist")
# Search for LaunchAgents by app name if unique enough
if [[ ${#app_name} -gt 3 ]]; then
while IFS= read -r -d '' plist; do while IFS= read -r -d '' plist; do
files_to_clean+=("$plist") files_to_clean+=("$plist")
done < <(find ~/Library/LaunchAgents -name "*$app_name*.plist" -print0 2> /dev/null) done < <(command find ~/Library/LaunchAgents -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null)
fi fi
# Application Scripts # Handle specialized toolchains and development environments
[[ -d ~/Library/Application\ Scripts/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Application Scripts/$bundle_id") # 1. DevEco-Studio (Huawei)
# Services
[[ -d ~/Library/Services/"$app_name".workflow ]] && files_to_clean+=("$HOME/Library/Services/$app_name.workflow")
# QuickLook Plugins
[[ -d ~/Library/QuickLook/"$app_name".qlgenerator ]] && files_to_clean+=("$HOME/Library/QuickLook/$app_name.qlgenerator")
# Internet Plug-Ins
[[ -d ~/Library/Internet\ Plug-Ins/"$app_name".plugin ]] && files_to_clean+=("$HOME/Library/Internet Plug-Ins/$app_name.plugin")
# Audio Plug-Ins (Components, VST, VST3)
[[ -d ~/Library/Audio/Plug-Ins/Components/"$app_name".component ]] && files_to_clean+=("$HOME/Library/Audio/Plug-Ins/Components/$app_name.component")
[[ -d ~/Library/Audio/Plug-Ins/VST/"$app_name".vst ]] && files_to_clean+=("$HOME/Library/Audio/Plug-Ins/VST/$app_name.vst")
[[ -d ~/Library/Audio/Plug-Ins/VST3/"$app_name".vst3 ]] && files_to_clean+=("$HOME/Library/Audio/Plug-Ins/VST3/$app_name.vst3")
[[ -d ~/Library/Audio/Plug-Ins/Digidesign/"$app_name".dpm ]] && files_to_clean+=("$HOME/Library/Audio/Plug-Ins/Digidesign/$app_name.dpm")
# Preference Panes
[[ -d ~/Library/PreferencePanes/"$app_name".prefPane ]] && files_to_clean+=("$HOME/Library/PreferencePanes/$app_name.prefPane")
# Screen Savers
[[ -d ~/Library/Screen\ Savers/"$app_name".saver ]] && files_to_clean+=("$HOME/Library/Screen Savers/$app_name.saver")
# Frameworks
[[ -d ~/Library/Frameworks/"$app_name".framework ]] && files_to_clean+=("$HOME/Library/Frameworks/$app_name.framework")
# Autosave Information
[[ -d ~/Library/Autosave\ Information/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Autosave Information/$bundle_id")
# Contextual Menu Items
[[ -d ~/Library/Contextual\ Menu\ Items/"$app_name".plugin ]] && files_to_clean+=("$HOME/Library/Contextual Menu Items/$app_name.plugin")
# Spotlight Plugins
[[ -d ~/Library/Spotlight/"$app_name".mdimporter ]] && files_to_clean+=("$HOME/Library/Spotlight/$app_name.mdimporter")
# Color Pickers
[[ -d ~/Library/ColorPickers/"$app_name".colorPicker ]] && files_to_clean+=("$HOME/Library/ColorPickers/$app_name.colorPicker")
# Workflows
[[ -d ~/Library/Workflows/"$app_name".workflow ]] && files_to_clean+=("$HOME/Library/Workflows/$app_name.workflow")
# Unix-style configuration directories and files (cross-platform apps)
[[ -d ~/.config/"$app_name" ]] && files_to_clean+=("$HOME/.config/$app_name")
[[ -d ~/.local/share/"$app_name" ]] && files_to_clean+=("$HOME/.local/share/$app_name")
[[ -d ~/."$app_name" ]] && files_to_clean+=("$HOME/.$app_name")
[[ -f ~/."${app_name}rc" ]] && files_to_clean+=("$HOME/.${app_name}rc")
# ============================================================================
# IDE-specific SDK and Toolchain directories
# ============================================================================
# DevEco-Studio (HarmonyOS/OpenHarmony IDE by Huawei)
if [[ "$app_name" =~ DevEco|deveco ]] || [[ "$bundle_id" =~ huawei.*deveco ]]; then if [[ "$app_name" =~ DevEco|deveco ]] || [[ "$bundle_id" =~ huawei.*deveco ]]; then
[[ -d ~/DevEcoStudioProjects ]] && files_to_clean+=("$HOME/DevEcoStudioProjects") for d in ~/DevEcoStudioProjects ~/DevEco-Studio ~/Library/Application\ Support/Huawei ~/Library/Caches/Huawei ~/Library/Logs/Huawei ~/Library/Huawei ~/Huawei ~/HarmonyOS ~/.huawei ~/.ohos; do
[[ -d ~/DevEco-Studio ]] && files_to_clean+=("$HOME/DevEco-Studio") [[ -d "$d" ]] && files_to_clean+=("$d")
[[ -d ~/Library/Application\ Support/Huawei ]] && files_to_clean+=("$HOME/Library/Application Support/Huawei") done
[[ -d ~/Library/Caches/Huawei ]] && files_to_clean+=("$HOME/Library/Caches/Huawei")
[[ -d ~/Library/Logs/Huawei ]] && files_to_clean+=("$HOME/Library/Logs/Huawei")
[[ -d ~/Library/Huawei ]] && files_to_clean+=("$HOME/Library/Huawei")
[[ -d ~/Huawei ]] && files_to_clean+=("$HOME/Huawei")
[[ -d ~/HarmonyOS ]] && files_to_clean+=("$HOME/HarmonyOS")
[[ -d ~/.huawei ]] && files_to_clean+=("$HOME/.huawei")
[[ -d ~/.ohos ]] && files_to_clean+=("$HOME/.ohos")
fi fi
# Android Studio # 2. Android Studio (Google)
if [[ "$app_name" =~ Android.*Studio|android.*studio ]] || [[ "$bundle_id" =~ google.*android.*studio|jetbrains.*android ]]; then if [[ "$app_name" =~ Android.*Studio|android.*studio ]] || [[ "$bundle_id" =~ google.*android.*studio|jetbrains.*android ]]; then
[[ -d ~/AndroidStudioProjects ]] && files_to_clean+=("$HOME/AndroidStudioProjects") for d in ~/AndroidStudioProjects ~/Library/Android ~/.android ~/.gradle; do
[[ -d ~/Library/Android ]] && files_to_clean+=("$HOME/Library/Android") [[ -d "$d" ]] && files_to_clean+=("$d")
[[ -d ~/.android ]] && files_to_clean+=("$HOME/.android") done
[[ -d ~/.gradle ]] && files_to_clean+=("$HOME/.gradle") [[ -d ~/Library/Application\ Support/Google ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find ~/Library/Application\ Support/Google -maxdepth 1 -name "AndroidStudio*" -print0 2> /dev/null)
[[ -d ~/Library/Application\ Support/Google ]] &&
while IFS= read -r -d '' dir; do files_to_clean+=("$dir"); done < <(find ~/Library/Application\ Support/Google -maxdepth 1 -name "AndroidStudio*" -print0 2> /dev/null)
fi fi
# Xcode # 3. Xcode (Apple)
if [[ "$app_name" =~ Xcode|xcode ]] || [[ "$bundle_id" =~ apple.*xcode ]]; then if [[ "$app_name" =~ Xcode|xcode ]] || [[ "$bundle_id" =~ apple.*xcode ]]; then
[[ -d ~/Library/Developer ]] && files_to_clean+=("$HOME/Library/Developer") [[ -d ~/Library/Developer ]] && files_to_clean+=("$HOME/Library/Developer")
[[ -d ~/.Xcode ]] && files_to_clean+=("$HOME/.Xcode") [[ -d ~/.Xcode ]] && files_to_clean+=("$HOME/.Xcode")
fi fi
# IntelliJ IDEA, PyCharm, WebStorm, etc. (JetBrains IDEs) # 4. JetBrains (IDE settings)
if [[ "$bundle_id" =~ jetbrains ]] || [[ "$app_name" =~ IntelliJ|PyCharm|WebStorm|GoLand|RubyMine|PhpStorm|CLion|DataGrip|Rider ]]; then if [[ "$bundle_id" =~ jetbrains ]] || [[ "$app_name" =~ IntelliJ|PyCharm|WebStorm|GoLand|RubyMine|PhpStorm|CLion|DataGrip|Rider ]]; then
local ide_name="$app_name" for base in ~/Library/Application\ Support/JetBrains ~/Library/Caches/JetBrains ~/Library/Logs/JetBrains; do
[[ -d ~/Library/Application\ Support/JetBrains ]] && [[ -d "$base" ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find "$base" -maxdepth 1 -name "${app_name}*" -print0 2> /dev/null)
while IFS= read -r -d '' dir; do files_to_clean+=("$dir"); done < <(find ~/Library/Application\ Support/JetBrains -maxdepth 1 -name "${ide_name}*" -print0 2> /dev/null) done
[[ -d ~/Library/Caches/JetBrains ]] &&
while IFS= read -r -d '' dir; do files_to_clean+=("$dir"); done < <(find ~/Library/Caches/JetBrains -maxdepth 1 -name "${ide_name}*" -print0 2> /dev/null)
[[ -d ~/Library/Logs/JetBrains ]] &&
while IFS= read -r -d '' dir; do files_to_clean+=("$dir"); done < <(find ~/Library/Logs/JetBrains -maxdepth 1 -name "${ide_name}*" -print0 2> /dev/null)
fi fi
# Unity # 5. Unity / Unreal / Godot
if [[ "$app_name" =~ Unity|unity ]] || [[ "$bundle_id" =~ unity ]]; then [[ "$app_name" =~ Unity|unity ]] && [[ -d ~/Library/Unity ]] && files_to_clean+=("$HOME/Library/Unity")
[[ -d ~/.local/share/unity3d ]] && files_to_clean+=("$HOME/.local/share/unity3d") [[ "$app_name" =~ Unreal|unreal ]] && [[ -d ~/Library/Application\ Support/Epic ]] && files_to_clean+=("$HOME/Library/Application Support/Epic")
[[ -d ~/Library/Unity ]] && files_to_clean+=("$HOME/Library/Unity") [[ "$app_name" =~ Godot|godot ]] && [[ -d ~/Library/Application\ Support/Godot ]] && files_to_clean+=("$HOME/Library/Application Support/Godot")
fi
# Unreal Engine # 6. Tools
if [[ "$app_name" =~ Unreal|unreal ]] || [[ "$bundle_id" =~ unrealengine|epicgames ]]; then [[ "$bundle_id" =~ microsoft.*vscode ]] && [[ -d ~/.vscode ]] && files_to_clean+=("$HOME/.vscode")
[[ -d ~/Library/Application\ Support/Epic ]] && files_to_clean+=("$HOME/Library/Application Support/Epic") [[ "$app_name" =~ Docker ]] && [[ -d ~/.docker ]] && files_to_clean+=("$HOME/.docker")
[[ -d ~/Documents/Unreal\ Projects ]] && files_to_clean+=("$HOME/Documents/Unreal Projects")
fi
# Visual Studio Code # Output results
if [[ "$bundle_id" =~ microsoft.*vscode|visualstudio.*code ]]; then
[[ -d ~/.vscode ]] && files_to_clean+=("$HOME/.vscode")
[[ -d ~/.vscode-insiders ]] && files_to_clean+=("$HOME/.vscode-insiders")
fi
# Flutter
if [[ "$app_name" =~ Flutter|flutter ]] || [[ "$bundle_id" =~ flutter ]]; then
[[ -d ~/.pub-cache ]] && files_to_clean+=("$HOME/.pub-cache")
[[ -d ~/flutter ]] && files_to_clean+=("$HOME/flutter")
fi
# Godot
if [[ "$app_name" =~ Godot|godot ]] || [[ "$bundle_id" =~ godot ]]; then
[[ -d ~/.local/share/godot ]] && files_to_clean+=("$HOME/.local/share/godot")
[[ -d ~/Library/Application\ Support/Godot ]] && files_to_clean+=("$HOME/Library/Application Support/Godot")
fi
# Docker Desktop
if [[ "$app_name" =~ Docker ]] || [[ "$bundle_id" =~ docker ]]; then
[[ -d ~/.docker ]] && files_to_clean+=("$HOME/.docker")
fi
# Only print if array has elements to avoid unbound variable error
if [[ ${#files_to_clean[@]} -gt 0 ]]; then if [[ ${#files_to_clean[@]} -gt 0 ]]; then
printf '%s\n' "${files_to_clean[@]}" printf '%s\n' "${files_to_clean[@]}"
fi fi
return 0
} }
# Find system-level app files (requires sudo) # Locate system-level application files
find_app_system_files() { find_app_system_files() {
local bundle_id="$1" local bundle_id="$1"
local app_name="$2" local app_name="$2"
local -a system_files=() local -a system_files=()
# System Application Support
[[ -d /Library/Application\ Support/"$app_name" ]] && system_files+=("/Library/Application Support/$app_name")
[[ -d /Library/Application\ Support/"$bundle_id" ]] && system_files+=("/Library/Application Support/$bundle_id")
# Sanitized App Name (remove spaces) # Sanitized App Name (remove spaces)
local nospace_name="${app_name// /}"
# Standard system path patterns
local -a system_patterns=(
"/Library/Application Support/$app_name"
"/Library/Application Support/$bundle_id"
"/Library/LaunchAgents/$bundle_id.plist"
"/Library/LaunchDaemons/$bundle_id.plist"
"/Library/Preferences/$bundle_id.plist"
"/Library/Receipts/$bundle_id.bom"
"/Library/Receipts/$bundle_id.plist"
"/Library/Frameworks/$app_name.framework"
"/Library/Internet Plug-Ins/$app_name.plugin"
"/Library/Audio/Plug-Ins/Components/$app_name.component"
"/Library/Audio/Plug-Ins/VST/$app_name.vst"
"/Library/Audio/Plug-Ins/VST3/$app_name.vst3"
"/Library/Audio/Plug-Ins/Digidesign/$app_name.dpm"
"/Library/QuickLook/$app_name.qlgenerator"
"/Library/PreferencePanes/$app_name.prefPane"
"/Library/Screen Savers/$app_name.saver"
"/Library/Caches/$bundle_id"
"/Library/Caches/$app_name"
)
if [[ ${#app_name} -gt 3 && "$app_name" =~ [[:space:]] ]]; then if [[ ${#app_name} -gt 3 && "$app_name" =~ [[:space:]] ]]; then
local nospace_name="${app_name// /}" system_patterns+=(
[[ -d /Library/Application\ Support/"$nospace_name" ]] && system_files+=("/Library/Application Support/$nospace_name") "/Library/Application Support/$nospace_name"
[[ -d /Library/Caches/"$nospace_name" ]] && system_files+=("/Library/Caches/$nospace_name") "/Library/Caches/$nospace_name"
[[ -d /Library/Logs/"$nospace_name" ]] && system_files+=("/Library/Logs/$nospace_name") "/Library/Logs/$nospace_name"
)
fi fi
# System Launch Agents # Process patterns
[[ -f /Library/LaunchAgents/"$bundle_id".plist ]] && system_files+=("/Library/LaunchAgents/$bundle_id.plist") for p in "${system_patterns[@]}"; do
# Search for LaunchAgents by app name if unique enough [[ ! -e "$p" ]] && continue
# Safety check: Skip if path ends with a common directory name (indicates empty app_name/bundle_id)
case "$p" in
/Library/Application\ Support | /Library/Application\ Support/ | \
/Library/Caches | /Library/Caches/ | \
/Library/Logs | /Library/Logs/)
continue
;;
esac
system_files+=("$p")
done
# System LaunchAgents/LaunchDaemons by name
if [[ ${#app_name} -gt 3 ]]; then if [[ ${#app_name} -gt 3 ]]; then
while IFS= read -r -d '' plist; do for base in /Library/LaunchAgents /Library/LaunchDaemons; do
system_files+=("$plist") [[ -d "$base" ]] && while IFS= read -r -d '' plist; do
done < <(find /Library/LaunchAgents -name "*$app_name*.plist" -print0 2> /dev/null) system_files+=("$plist")
done < <(command find "$base" -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null)
done
fi fi
# System Launch Daemons # Privileged Helper Tools and Receipts (special handling)
[[ -f /Library/LaunchDaemons/"$bundle_id".plist ]] && system_files+=("/Library/LaunchDaemons/$bundle_id.plist") # Only search with bundle_id if it's valid (not empty and not "unknown")
# Search for LaunchDaemons by app name if unique enough if [[ -n "$bundle_id" && "$bundle_id" != "unknown" && ${#bundle_id} -gt 3 ]]; then
if [[ ${#app_name} -gt 3 ]]; then [[ -d /Library/PrivilegedHelperTools ]] && while IFS= read -r -d '' helper; do
while IFS= read -r -d '' plist; do system_files+=("$helper")
system_files+=("$plist") done < <(command find /Library/PrivilegedHelperTools -maxdepth 1 \( -name "$bundle_id*" \) -print0 2> /dev/null)
done < <(find /Library/LaunchDaemons -name "*$app_name*.plist" -print0 2> /dev/null)
[[ -d /private/var/db/receipts ]] && while IFS= read -r -d '' receipt; do
system_files+=("$receipt")
done < <(command find /private/var/db/receipts -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null)
fi fi
# Privileged Helper Tools
[[ -d /Library/PrivilegedHelperTools ]] && while IFS= read -r -d '' helper; do
system_files+=("$helper")
done < <(find /Library/PrivilegedHelperTools \( -name "$bundle_id*" \) -print0 2> /dev/null)
# System Preferences
[[ -f /Library/Preferences/"$bundle_id".plist ]] && system_files+=("/Library/Preferences/$bundle_id.plist")
# Installation Receipts
[[ -d /private/var/db/receipts ]] && while IFS= read -r -d '' receipt; do
system_files+=("$receipt")
done < <(find /private/var/db/receipts \( -name "*$bundle_id*" \) -print0 2> /dev/null)
# System Logs
[[ -d /Library/Logs/"$app_name" ]] && system_files+=("/Library/Logs/$app_name")
[[ -d /Library/Logs/"$bundle_id" ]] && system_files+=("/Library/Logs/$bundle_id")
# System Frameworks
[[ -d /Library/Frameworks/"$app_name".framework ]] && system_files+=("/Library/Frameworks/$app_name.framework")
# System Internet Plug-Ins
[[ -d /Library/Internet\ Plug-Ins/"$app_name".plugin ]] && system_files+=("/Library/Internet Plug-Ins/$app_name.plugin")
# System Audio Plug-Ins
[[ -d /Library/Audio/Plug-Ins/Components/"$app_name".component ]] && system_files+=("/Library/Audio/Plug-Ins/Components/$app_name.component")
[[ -d /Library/Audio/Plug-Ins/VST/"$app_name".vst ]] && system_files+=("/Library/Audio/Plug-Ins/VST/$app_name.vst")
[[ -d /Library/Audio/Plug-Ins/VST3/"$app_name".vst3 ]] && system_files+=("/Library/Audio/Plug-Ins/VST3/$app_name.vst3")
[[ -d /Library/Audio/Plug-Ins/Digidesign/"$app_name".dpm ]] && system_files+=("/Library/Audio/Plug-Ins/Digidesign/$app_name.dpm")
# System QuickLook Plugins
[[ -d /Library/QuickLook/"$app_name".qlgenerator ]] && system_files+=("/Library/QuickLook/$app_name.qlgenerator")
# System Preference Panes
[[ -d /Library/PreferencePanes/"$app_name".prefPane ]] && system_files+=("/Library/PreferencePanes/$app_name.prefPane")
# System Screen Savers
[[ -d /Library/Screen\ Savers/"$app_name".saver ]] && system_files+=("/Library/Screen Savers/$app_name.saver")
# System Caches
[[ -d /Library/Caches/"$bundle_id" ]] && system_files+=("/Library/Caches/$bundle_id")
[[ -d /Library/Caches/"$app_name" ]] && system_files+=("/Library/Caches/$app_name")
# Only print if array has elements
if [[ ${#system_files[@]} -gt 0 ]]; then if [[ ${#system_files[@]} -gt 0 ]]; then
printf '%s\n' "${system_files[@]}" printf '%s\n' "${system_files[@]}"
fi fi
@@ -758,7 +844,7 @@ find_app_system_files() {
find_app_receipt_files "$bundle_id" find_app_receipt_files "$bundle_id"
} }
# Find files from installation receipts (Bom files) # Locate files using installation receipts (BOM)
find_app_receipt_files() { find_app_receipt_files() {
local bundle_id="$1" local bundle_id="$1"
@@ -773,7 +859,7 @@ find_app_receipt_files() {
if [[ -d /private/var/db/receipts ]]; then if [[ -d /private/var/db/receipts ]]; then
while IFS= read -r -d '' bom; do while IFS= read -r -d '' bom; do
bom_files+=("$bom") bom_files+=("$bom")
done < <(find /private/var/db/receipts -name "${bundle_id}*.bom" -print0 2> /dev/null) done < <(find /private/var/db/receipts -maxdepth 1 -name "${bundle_id}*.bom" -print0 2> /dev/null)
fi fi
# Process bom files if any found # Process bom files if any found
@@ -791,13 +877,13 @@ find_app_receipt_files() {
# Standardize path (remove leading dot) # Standardize path (remove leading dot)
local clean_path="${file_path#.}" local clean_path="${file_path#.}"
# Ensure it starts with / # Ensure absolute path
if [[ "$clean_path" != /* ]]; then if [[ "$clean_path" != /* ]]; then
clean_path="/$clean_path" clean_path="/$clean_path"
fi fi
# ------------------------------------------------------------------------ # ------------------------------------------------------------------------
# SAFETY FILTER: Only allow specific removal paths # Safety check: restrict removal to trusted paths
# ------------------------------------------------------------------------ # ------------------------------------------------------------------------
local is_safe=false local is_safe=false
@@ -832,15 +918,7 @@ find_app_receipt_files() {
esac esac
if [[ "$is_safe" == "true" && -e "$clean_path" ]]; then if [[ "$is_safe" == "true" && -e "$clean_path" ]]; then
# Only valid files # If lsbom lists /Applications, skip to avoid system damage.
# Don't delete directories if they are non-empty parents?
# lsbom lists directories too.
# If we return a directory, `safe_remove` logic handles it.
# `uninstall.sh` uses `remove_file_list`.
# If `lsbom` lists `/Applications` (it shouldn't, only contents), we must be careful.
# `lsbom` usually lists `./Applications/MyApp.app`.
# If it lists `./Applications`, we must skip it.
# Extra check: path must be deep enough? # Extra check: path must be deep enough?
# If path is just "/Applications", skip. # If path is just "/Applications", skip.
if [[ "$clean_path" == "/Applications" || "$clean_path" == "/Library" || "$clean_path" == "/usr/local" ]]; then if [[ "$clean_path" == "/Applications" || "$clean_path" == "/Library" || "$clean_path" == "/usr/local" ]]; then
@@ -858,9 +936,9 @@ find_app_receipt_files() {
fi fi
} }
# Force quit an application # Terminate a running application
force_kill_app() { force_kill_app() {
# Args: app_name [app_path]; tries graceful then force kill; returns 0 if stopped, 1 otherwise # Gracefully terminates or force-kills an application
local app_name="$1" local app_name="$1"
local app_path="${2:-""}" local app_path="${2:-""}"
@@ -913,18 +991,4 @@ force_kill_app() {
pgrep -x "$match_pattern" > /dev/null 2>&1 && return 1 || return 0 pgrep -x "$match_pattern" > /dev/null 2>&1 && return 1 || return 0
} }
# Calculate total size of files (consolidated from duplicates) # Note: calculate_total_size() is defined in lib/core/file_ops.sh
calculate_total_size() {
local files="$1"
local total_kb=0
while IFS= read -r file; do
if [[ -n "$file" && -e "$file" ]]; then
local size_kb
size_kb=$(get_path_size_kb "$file")
((total_kb += size_kb))
fi
done <<< "$files"
echo "$total_kb"
}

View File

@@ -31,27 +31,29 @@ readonly ICON_CONFIRM="◎"
readonly ICON_ADMIN="⚙" readonly ICON_ADMIN="⚙"
readonly ICON_SUCCESS="✓" readonly ICON_SUCCESS="✓"
readonly ICON_ERROR="☻" readonly ICON_ERROR="☻"
readonly ICON_WARNING="●"
readonly ICON_EMPTY="○" readonly ICON_EMPTY="○"
readonly ICON_SOLID="●" readonly ICON_SOLID="●"
readonly ICON_LIST="•" readonly ICON_LIST="•"
readonly ICON_ARROW="➤" readonly ICON_ARROW="➤"
readonly ICON_WARNING="" readonly ICON_DRY_RUN=""
readonly ICON_NAV_UP="↑" readonly ICON_NAV_UP="↑"
readonly ICON_NAV_DOWN="↓" readonly ICON_NAV_DOWN="↓"
readonly ICON_NAV_LEFT="←"
readonly ICON_NAV_RIGHT="→"
# ============================================================================ # ============================================================================
# Global Configuration Constants # Global Configuration Constants
# ============================================================================ # ============================================================================
readonly MOLE_TEMP_FILE_AGE_DAYS=7 # Temp file cleanup threshold readonly MOLE_TEMP_FILE_AGE_DAYS=7 # Temp file retention (days)
readonly MOLE_ORPHAN_AGE_DAYS=60 # Orphaned data threshold readonly MOLE_ORPHAN_AGE_DAYS=60 # Orphaned data retention (days)
readonly MOLE_MAX_PARALLEL_JOBS=15 # Parallel job limit readonly MOLE_MAX_PARALLEL_JOBS=15 # Parallel job limit
readonly MOLE_MAIL_DOWNLOADS_MIN_KB=5120 # Mail attachments size threshold readonly MOLE_MAIL_DOWNLOADS_MIN_KB=5120 # Mail attachment size threshold
readonly MOLE_LOG_AGE_DAYS=7 # System log retention readonly MOLE_MAIL_AGE_DAYS=30 # Mail attachment retention (days)
readonly MOLE_CRASH_REPORT_AGE_DAYS=7 # Crash report retention readonly MOLE_LOG_AGE_DAYS=7 # Log retention (days)
readonly MOLE_SAVED_STATE_AGE_DAYS=7 # App saved state retention readonly MOLE_CRASH_REPORT_AGE_DAYS=7 # Crash report retention (days)
readonly MOLE_TM_BACKUP_SAFE_HOURS=48 # Time Machine failed backup safety window readonly MOLE_SAVED_STATE_AGE_DAYS=30 # Saved state retention (days) - increased for safety
readonly MOLE_TM_BACKUP_SAFE_HOURS=48 # TM backup safety window (hours)
readonly MOLE_MAX_DS_STORE_FILES=500 # Max .DS_Store files to clean per scan
readonly MOLE_MAX_ORPHAN_ITERATIONS=100 # Max iterations for orphaned app data scan
# ============================================================================ # ============================================================================
# Seasonal Functions # Seasonal Functions
@@ -80,14 +82,21 @@ declare -a DEFAULT_WHITELIST_PATTERNS=(
"$HOME/Library/Caches/com.nssurge.surge-mac/*" "$HOME/Library/Caches/com.nssurge.surge-mac/*"
"$HOME/Library/Application Support/com.nssurge.surge-mac/*" "$HOME/Library/Application Support/com.nssurge.surge-mac/*"
"$HOME/Library/Caches/org.R-project.R/R/renv/*" "$HOME/Library/Caches/org.R-project.R/R/renv/*"
"$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/Caches/com.apple.finder" "$HOME/Library/Caches/com.apple.finder"
"$HOME/Library/Mobile Documents*"
# System-critical caches that affect macOS functionality and stability
# CRITICAL: Removing these will cause system search and UI issues
"$HOME/Library/Caches/com.apple.FontRegistry*"
"$HOME/Library/Caches/com.apple.spotlight*"
"$HOME/Library/Caches/com.apple.Spotlight*"
"$HOME/Library/Caches/CloudKit*"
"$FINDER_METADATA_SENTINEL" "$FINDER_METADATA_SENTINEL"
) )
declare -a DEFAULT_OPTIMIZE_WHITELIST_PATTERNS=( declare -a DEFAULT_OPTIMIZE_WHITELIST_PATTERNS=(
"check_brew_updates"
"check_brew_health" "check_brew_health"
"check_touchid" "check_touchid"
"check_git_config" "check_git_config"
@@ -98,7 +107,7 @@ declare -a DEFAULT_OPTIMIZE_WHITELIST_PATTERNS=(
# ============================================================================ # ============================================================================
readonly STAT_BSD="/usr/bin/stat" readonly STAT_BSD="/usr/bin/stat"
# Get file size in bytes using BSD stat # Get file size in bytes
get_file_size() { get_file_size() {
local file="$1" local file="$1"
local result local result
@@ -106,8 +115,7 @@ get_file_size() {
echo "${result:-0}" echo "${result:-0}"
} }
# Get file modification time using BSD stat # Get file modification time in epoch seconds
# Returns: epoch seconds
get_file_mtime() { get_file_mtime() {
local file="$1" local file="$1"
[[ -z "$file" ]] && { [[ -z "$file" ]] && {
@@ -119,7 +127,7 @@ get_file_mtime() {
echo "${result:-0}" echo "${result:-0}"
} }
# Get file owner username using BSD stat # Get file owner username
get_file_owner() { get_file_owner() {
local file="$1" local file="$1"
$STAT_BSD -f%Su "$file" 2> /dev/null || echo "" $STAT_BSD -f%Su "$file" 2> /dev/null || echo ""
@@ -146,8 +154,7 @@ is_sip_enabled() {
fi fi
} }
# Check if running in interactive terminal # Check if running in an interactive terminal
# Returns: 0 if interactive, 1 otherwise
is_interactive() { is_interactive() {
[[ -t 1 ]] [[ -t 1 ]]
} }
@@ -165,12 +172,36 @@ detect_architecture() {
# Get free disk space on root volume # Get free disk space on root volume
# Returns: human-readable string (e.g., "100G") # Returns: human-readable string (e.g., "100G")
get_free_space() { get_free_space() {
command df -h / | awk 'NR==2 {print $4}' local target="/"
if [[ -d "/System/Volumes/Data" ]]; then
target="/System/Volumes/Data"
fi
df -h "$target" | awk 'NR==2 {print $4}'
} }
# Get optimal number of parallel jobs for a given operation type # Get Darwin kernel major version (e.g., 24 for 24.2.0)
# Args: $1 - operation type (scan|io|compute|default) # Returns 999 on failure to adopt conservative behavior (assume modern system)
# Returns: number of jobs get_darwin_major() {
local kernel
kernel=$(uname -r 2> /dev/null || true)
local major="${kernel%%.*}"
if [[ ! "$major" =~ ^[0-9]+$ ]]; then
# Return high number to skip potentially dangerous operations on unknown systems
major=999
fi
echo "$major"
}
# Check if Darwin kernel major version meets minimum
is_darwin_ge() {
local minimum="$1"
local major
major=$(get_darwin_major)
[[ "$major" -ge "$minimum" ]]
}
# Get optimal parallel jobs for operation type (scan|io|compute|default)
get_optimal_parallel_jobs() { get_optimal_parallel_jobs() {
local operation_type="${1:-default}" local operation_type="${1:-default}"
local cpu_cores local cpu_cores
@@ -188,53 +219,214 @@ get_optimal_parallel_jobs() {
esac esac
} }
# ============================================================================
# User Context Utilities
# ============================================================================
is_root_user() {
[[ "$(id -u)" == "0" ]]
}
get_user_home() {
local user="$1"
local home=""
if [[ -z "$user" ]]; then
echo ""
return 0
fi
if command -v dscl > /dev/null 2>&1; then
home=$(dscl . -read "/Users/$user" NFSHomeDirectory 2> /dev/null | awk '{print $2}' | head -1 || true)
fi
if [[ -z "$home" ]]; then
home=$(eval echo "~$user" 2> /dev/null || true)
fi
if [[ "$home" == "~"* ]]; then
home=""
fi
echo "$home"
}
get_invoking_user() {
if [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]]; then
echo "$SUDO_USER"
return 0
fi
echo "${USER:-}"
}
get_invoking_uid() {
if [[ -n "${SUDO_UID:-}" ]]; then
echo "$SUDO_UID"
return 0
fi
local uid
uid=$(id -u 2> /dev/null || true)
echo "$uid"
}
get_invoking_gid() {
if [[ -n "${SUDO_GID:-}" ]]; then
echo "$SUDO_GID"
return 0
fi
local gid
gid=$(id -g 2> /dev/null || true)
echo "$gid"
}
get_invoking_home() {
if [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]]; then
get_user_home "$SUDO_USER"
return 0
fi
echo "${HOME:-}"
}
ensure_user_dir() {
local raw_path="$1"
if [[ -z "$raw_path" ]]; then
return 0
fi
local target_path="$raw_path"
if [[ "$target_path" == "~"* ]]; then
target_path="${target_path/#\~/$HOME}"
fi
mkdir -p "$target_path" 2> /dev/null || true
if ! is_root_user; then
return 0
fi
local sudo_user="${SUDO_USER:-}"
if [[ -z "$sudo_user" || "$sudo_user" == "root" ]]; then
return 0
fi
local user_home
user_home=$(get_user_home "$sudo_user")
if [[ -z "$user_home" ]]; then
return 0
fi
user_home="${user_home%/}"
if [[ "$target_path" != "$user_home" && "$target_path" != "$user_home/"* ]]; then
return 0
fi
local owner_uid="${SUDO_UID:-}"
local owner_gid="${SUDO_GID:-}"
if [[ -z "$owner_uid" || -z "$owner_gid" ]]; then
owner_uid=$(id -u "$sudo_user" 2> /dev/null || true)
owner_gid=$(id -g "$sudo_user" 2> /dev/null || true)
fi
if [[ -z "$owner_uid" || -z "$owner_gid" ]]; then
return 0
fi
local dir="$target_path"
while [[ -n "$dir" && "$dir" != "/" ]]; do
# Early stop: if ownership is already correct, no need to continue up the tree
if [[ -d "$dir" ]]; then
local current_uid
current_uid=$("$STAT_BSD" -f%u "$dir" 2> /dev/null || echo "")
if [[ "$current_uid" == "$owner_uid" ]]; then
break
fi
fi
chown "$owner_uid:$owner_gid" "$dir" 2> /dev/null || true
if [[ "$dir" == "$user_home" ]]; then
break
fi
dir=$(dirname "$dir")
if [[ "$dir" == "." ]]; then
break
fi
done
}
ensure_user_file() {
local raw_path="$1"
if [[ -z "$raw_path" ]]; then
return 0
fi
local target_path="$raw_path"
if [[ "$target_path" == "~"* ]]; then
target_path="${target_path/#\~/$HOME}"
fi
ensure_user_dir "$(dirname "$target_path")"
touch "$target_path" 2> /dev/null || true
if ! is_root_user; then
return 0
fi
local sudo_user="${SUDO_USER:-}"
if [[ -z "$sudo_user" || "$sudo_user" == "root" ]]; then
return 0
fi
local user_home
user_home=$(get_user_home "$sudo_user")
if [[ -z "$user_home" ]]; then
return 0
fi
user_home="${user_home%/}"
if [[ "$target_path" != "$user_home" && "$target_path" != "$user_home/"* ]]; then
return 0
fi
local owner_uid="${SUDO_UID:-}"
local owner_gid="${SUDO_GID:-}"
if [[ -z "$owner_uid" || -z "$owner_gid" ]]; then
owner_uid=$(id -u "$sudo_user" 2> /dev/null || true)
owner_gid=$(id -g "$sudo_user" 2> /dev/null || true)
fi
if [[ -n "$owner_uid" && -n "$owner_gid" ]]; then
chown "$owner_uid:$owner_gid" "$target_path" 2> /dev/null || true
fi
}
# ============================================================================ # ============================================================================
# Formatting Utilities # Formatting Utilities
# ============================================================================ # ============================================================================
# Convert bytes to human-readable format # Convert bytes to human-readable format (e.g., 1.5GB)
# Args: $1 - size in bytes
# Returns: formatted string (e.g., "1.50GB", "256MB", "4KB")
bytes_to_human() { bytes_to_human() {
local bytes="$1" local bytes="$1"
if [[ ! "$bytes" =~ ^[0-9]+$ ]]; then [[ "$bytes" =~ ^[0-9]+$ ]] || {
echo "0B" echo "0B"
return 1 return 1
fi }
if ((bytes >= 1073741824)); then # >= 1GB # GB: >= 1073741824 bytes
local divisor=1073741824 if ((bytes >= 1073741824)); then
local whole=$((bytes / divisor)) printf "%d.%02dGB\n" $((bytes / 1073741824)) $(((bytes % 1073741824) * 100 / 1073741824))
local remainder=$((bytes % divisor)) # MB: >= 1048576 bytes
local frac=$(((remainder * 100 + divisor / 2) / divisor)) elif ((bytes >= 1048576)); then
if ((frac >= 100)); then printf "%d.%01dMB\n" $((bytes / 1048576)) $(((bytes % 1048576) * 10 / 1048576))
frac=0 # KB: >= 1024 bytes (round up)
((whole++)) elif ((bytes >= 1024)); then
fi printf "%dKB\n" $(((bytes + 512) / 1024))
printf "%d.%02dGB\n" "$whole" "$frac" else
return 0 printf "%dB\n" "$bytes"
fi fi
if ((bytes >= 1048576)); then # >= 1MB
local divisor=1048576
local whole=$((bytes / divisor))
local remainder=$((bytes % divisor))
local frac=$(((remainder * 10 + divisor / 2) / divisor))
if ((frac >= 10)); then
frac=0
((whole++))
fi
printf "%d.%01dMB\n" "$whole" "$frac"
return 0
fi
if ((bytes >= 1024)); then
local rounded_kb=$(((bytes + 512) / 1024))
printf "%dKB\n" "$rounded_kb"
return 0
fi
printf "%dB\n" "$bytes"
} }
# Convert kilobytes to human-readable format # Convert kilobytes to human-readable format
@@ -244,17 +436,22 @@ bytes_to_human_kb() {
bytes_to_human "$((${1:-0} * 1024))" bytes_to_human "$((${1:-0} * 1024))"
} }
# Get brand-friendly name for an application # Get brand-friendly localized name for an application
# Args: $1 - application name
# Returns: localized name based on system language preference
get_brand_name() { get_brand_name() {
local name="$1" local name="$1"
# Detect if system primary language is Chinese # Detect if system primary language is Chinese (Cached)
local is_chinese=false if [[ -z "${MOLE_IS_CHINESE_SYSTEM:-}" ]]; then
local sys_lang local sys_lang
sys_lang=$(defaults read -g AppleLanguages 2> /dev/null | grep -o 'zh-Hans\|zh-Hant\|zh' | head -1 || echo "") sys_lang=$(defaults read -g AppleLanguages 2> /dev/null | grep -o 'zh-Hans\|zh-Hant\|zh' | head -1 || echo "")
[[ -n "$sys_lang" ]] && is_chinese=true if [[ -n "$sys_lang" ]]; then
export MOLE_IS_CHINESE_SYSTEM="true"
else
export MOLE_IS_CHINESE_SYSTEM="false"
fi
fi
local is_chinese="${MOLE_IS_CHINESE_SYSTEM}"
# Return localized names based on system language # Return localized names based on system language
if [[ "$is_chinese" == true ]]; then if [[ "$is_chinese" == true ]]; then
@@ -304,7 +501,6 @@ declare -a MOLE_TEMP_FILES=()
declare -a MOLE_TEMP_DIRS=() declare -a MOLE_TEMP_DIRS=()
# Create tracked temporary file # Create tracked temporary file
# Returns: temp file path
create_temp_file() { create_temp_file() {
local temp local temp
temp=$(mktemp) || return 1 temp=$(mktemp) || return 1
@@ -313,7 +509,6 @@ create_temp_file() {
} }
# Create tracked temporary directory # Create tracked temporary directory
# Returns: temp directory path
create_temp_dir() { create_temp_dir() {
local temp local temp
temp=$(mktemp -d) || return 1 temp=$(mktemp -d) || return 1
@@ -342,6 +537,7 @@ mktemp_file() {
# Cleanup all tracked temp files and directories # Cleanup all tracked temp files and directories
cleanup_temp_files() { cleanup_temp_files() {
stop_inline_spinner 2> /dev/null || true
local file local file
if [[ ${#MOLE_TEMP_FILES[@]} -gt 0 ]]; then if [[ ${#MOLE_TEMP_FILES[@]} -gt 0 ]]; then
for file in "${MOLE_TEMP_FILES[@]}"; do for file in "${MOLE_TEMP_FILES[@]}"; do
@@ -379,7 +575,7 @@ start_section() {
# End a section # End a section
# Shows "Nothing to tidy" if no activity was recorded # Shows "Nothing to tidy" if no activity was recorded
end_section() { end_section() {
if [[ $TRACK_SECTION -eq 1 && $SECTION_ACTIVITY -eq 0 ]]; then if [[ "${TRACK_SECTION:-0}" == "1" && "${SECTION_ACTIVITY:-0}" == "0" ]]; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Nothing to tidy" echo -e " ${GREEN}${ICON_SUCCESS}${NC} Nothing to tidy"
fi fi
TRACK_SECTION=0 TRACK_SECTION=0
@@ -387,7 +583,272 @@ end_section() {
# Mark activity in current section # Mark activity in current section
note_activity() { note_activity() {
if [[ $TRACK_SECTION -eq 1 ]]; then if [[ "${TRACK_SECTION:-0}" == "1" ]]; then
SECTION_ACTIVITY=1 SECTION_ACTIVITY=1
fi fi
} }
# Start a section spinner with optional message
# Usage: start_section_spinner "message"
start_section_spinner() {
local message="${1:-Scanning...}"
stop_inline_spinner 2> /dev/null || true
if [[ -t 1 ]]; then
MOLE_SPINNER_PREFIX=" " start_inline_spinner "$message"
fi
}
# Stop spinner and clear the line
# Usage: stop_section_spinner
stop_section_spinner() {
stop_inline_spinner 2> /dev/null || true
if [[ -t 1 ]]; then
echo -ne "\r\033[K" >&2 || true
fi
}
# Safe terminal line clearing with terminal type detection
# Usage: safe_clear_lines <num_lines> [tty_device]
# Returns: 0 on success, 1 if terminal doesn't support ANSI
safe_clear_lines() {
local lines="${1:-1}"
local tty_device="${2:-/dev/tty}"
# Use centralized ANSI support check (defined below)
# Note: This forward reference works because functions are parsed before execution
is_ansi_supported 2> /dev/null || return 1
# Clear lines one by one (more reliable than multi-line sequences)
local i
for ((i = 0; i < lines; i++)); do
printf "\033[1A\r\033[K" > "$tty_device" 2> /dev/null || return 1
done
return 0
}
# Safe single line clear with fallback
# Usage: safe_clear_line [tty_device]
safe_clear_line() {
local tty_device="${1:-/dev/tty}"
# Use centralized ANSI support check
is_ansi_supported 2> /dev/null || return 1
printf "\r\033[K" > "$tty_device" 2> /dev/null || return 1
return 0
}
# Update progress spinner if enough time has elapsed
# Usage: update_progress_if_needed <completed> <total> <last_update_time_var> [interval]
# Example: update_progress_if_needed "$completed" "$total" last_progress_update 2
# Returns: 0 if updated, 1 if skipped
update_progress_if_needed() {
local completed="$1"
local total="$2"
local last_update_var="$3" # Name of variable holding last update time
local interval="${4:-2}" # Default: update every 2 seconds
# Get current time
local current_time=$(date +%s)
# Get last update time from variable
local last_time
eval "last_time=\${$last_update_var:-0}"
# Check if enough time has elapsed
if [[ $((current_time - last_time)) -ge $interval ]]; then
# Update the spinner with progress
stop_section_spinner
start_section_spinner "Scanning items... ($completed/$total)"
# Update the last_update_time variable
eval "$last_update_var=$current_time"
return 0
fi
return 1
}
# ============================================================================
# Spinner Stack Management (prevents nesting issues)
# ============================================================================
# Global spinner stack
declare -a MOLE_SPINNER_STACK=()
# Push current spinner state onto stack
# Usage: push_spinner_state
push_spinner_state() {
local current_state=""
# Save current spinner PID if running
if [[ -n "${MOLE_SPINNER_PID:-}" ]] && kill -0 "$MOLE_SPINNER_PID" 2> /dev/null; then
current_state="running:$MOLE_SPINNER_PID"
else
current_state="stopped"
fi
MOLE_SPINNER_STACK+=("$current_state")
debug_log "Pushed spinner state: $current_state (stack depth: ${#MOLE_SPINNER_STACK[@]})"
}
# Pop and restore spinner state from stack
# Usage: pop_spinner_state
pop_spinner_state() {
if [[ ${#MOLE_SPINNER_STACK[@]} -eq 0 ]]; then
debug_log "Warning: Attempted to pop from empty spinner stack"
return 1
fi
# Stack depth safety check
if [[ ${#MOLE_SPINNER_STACK[@]} -gt 10 ]]; then
debug_log "Warning: Spinner stack depth excessive (${#MOLE_SPINNER_STACK[@]}), possible leak"
fi
local last_idx=$((${#MOLE_SPINNER_STACK[@]} - 1))
local state="${MOLE_SPINNER_STACK[$last_idx]}"
# Remove from stack (Bash 3.2 compatible way)
# Instead of unset, rebuild array without last element
local -a new_stack=()
local i
for ((i = 0; i < last_idx; i++)); do
new_stack+=("${MOLE_SPINNER_STACK[$i]}")
done
MOLE_SPINNER_STACK=("${new_stack[@]}")
debug_log "Popped spinner state: $state (remaining depth: ${#MOLE_SPINNER_STACK[@]})"
# Restore state if needed
if [[ "$state" == running:* ]]; then
# Previous spinner was running - we don't restart it automatically
# This is intentional to avoid UI conflicts
:
fi
return 0
}
# Safe spinner start with stack management
# Usage: safe_start_spinner <message>
safe_start_spinner() {
local message="${1:-Working...}"
# Push current state
push_spinner_state
# Stop any existing spinner
stop_section_spinner 2> /dev/null || true
# Start new spinner
start_section_spinner "$message"
}
# Safe spinner stop with stack management
# Usage: safe_stop_spinner
safe_stop_spinner() {
# Stop current spinner
stop_section_spinner 2> /dev/null || true
# Pop previous state
pop_spinner_state || true
}
# ============================================================================
# Terminal Compatibility Checks
# ============================================================================
# Check if terminal supports ANSI escape codes
# Usage: is_ansi_supported
# Returns: 0 if supported, 1 if not
is_ansi_supported() {
# Check if running in interactive terminal
[[ -t 1 ]] || return 1
# Check TERM variable
[[ -n "${TERM:-}" ]] || return 1
# Check for known ANSI-compatible terminals
case "$TERM" in
xterm* | vt100 | vt220 | screen* | tmux* | ansi | linux | rxvt* | konsole*)
return 0
;;
dumb | unknown)
return 1
;;
*)
# Check terminfo database if available
if command -v tput > /dev/null 2>&1; then
# Test if terminal supports colors (good proxy for ANSI support)
local colors=$(tput colors 2> /dev/null || echo "0")
[[ "$colors" -ge 8 ]] && return 0
fi
return 1
;;
esac
}
# Get terminal capability info
# Usage: get_terminal_info
get_terminal_info() {
local info="Terminal: ${TERM:-unknown}"
if is_ansi_supported; then
info+=" (ANSI supported)"
if command -v tput > /dev/null 2>&1; then
local cols=$(tput cols 2> /dev/null || echo "?")
local lines=$(tput lines 2> /dev/null || echo "?")
local colors=$(tput colors 2> /dev/null || echo "?")
info+=" ${cols}x${lines}, ${colors} colors"
fi
else
info+=" (ANSI not supported)"
fi
echo "$info"
}
# Validate terminal environment before running
# Usage: validate_terminal_environment
# Returns: 0 if OK, 1 with warning if issues detected
validate_terminal_environment() {
local warnings=0
# Check if TERM is set
if [[ -z "${TERM:-}" ]]; then
log_warning "TERM environment variable not set"
((warnings++))
fi
# Check if running in a known problematic terminal
case "${TERM:-}" in
dumb)
log_warning "Running in 'dumb' terminal - limited functionality"
((warnings++))
;;
unknown)
log_warning "Terminal type unknown - may have display issues"
((warnings++))
;;
esac
# Check terminal size if available
if command -v tput > /dev/null 2>&1; then
local cols=$(tput cols 2> /dev/null || echo "80")
if [[ "$cols" -lt 60 ]]; then
log_warning "Terminal width ($cols cols) is narrow - output may wrap"
((warnings++))
fi
fi
# Report compatibility
if [[ $warnings -eq 0 ]]; then
debug_log "Terminal environment validated: $(get_terminal_info)"
return 0
else
debug_log "Terminal compatibility warnings: $warnings"
return 1
fi
}

17
lib/core/commands.sh Normal file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
# Shared command list for help text and completions.
MOLE_COMMANDS=(
"clean:Free up disk space"
"uninstall:Remove apps completely"
"optimize:Check and maintain system"
"analyze:Explore disk usage"
"status:Monitor system health"
"purge:Remove old project artifacts"
"touchid:Configure Touch ID for sudo"
"completion:Setup shell tab completion"
"update:Update to latest version"
"remove:Remove Mole from system"
"help:Show help"
"version:Show version"
)

View File

@@ -12,7 +12,7 @@ readonly MOLE_COMMON_LOADED=1
_MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Load core modules in dependency order # Load core modules
source "$_MOLE_CORE_DIR/base.sh" source "$_MOLE_CORE_DIR/base.sh"
source "$_MOLE_CORE_DIR/log.sh" source "$_MOLE_CORE_DIR/log.sh"
@@ -26,32 +26,55 @@ if [[ -f "$_MOLE_CORE_DIR/sudo.sh" ]]; then
source "$_MOLE_CORE_DIR/sudo.sh" source "$_MOLE_CORE_DIR/sudo.sh"
fi fi
# Update Mole via Homebrew # Update via Homebrew
# Args: $1 = current version
update_via_homebrew() { update_via_homebrew() {
local current_version="$1" local current_version="$1"
local temp_update temp_upgrade
temp_update=$(mktemp_file "brew_update")
temp_upgrade=$(mktemp_file "brew_upgrade")
# Set up trap for interruption (Ctrl+C) with inline cleanup
trap 'stop_inline_spinner 2>/dev/null; rm -f "$temp_update" "$temp_upgrade" 2>/dev/null; echo ""; exit 130' INT TERM
# Update Homebrew
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
start_inline_spinner "Updating Homebrew..." start_inline_spinner "Updating Homebrew..."
else else
echo "Updating Homebrew..." echo "Updating Homebrew..."
fi fi
brew update 2>&1 | grep -Ev "^(==>|Already up-to-date)" || true
brew update > "$temp_update" 2>&1 &
local update_pid=$!
wait $update_pid 2> /dev/null || true # Continue even if brew update fails
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
stop_inline_spinner stop_inline_spinner
fi fi
# Upgrade Mole
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
start_inline_spinner "Upgrading Mole..." start_inline_spinner "Upgrading Mole..."
else else
echo "Upgrading Mole..." echo "Upgrading Mole..."
fi fi
brew upgrade mole > "$temp_upgrade" 2>&1 &
local upgrade_pid=$!
wait $upgrade_pid 2> /dev/null || true # Continue even if brew upgrade fails
local upgrade_output local upgrade_output
upgrade_output=$(brew upgrade mole 2>&1) || true upgrade_output=$(cat "$temp_upgrade")
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
stop_inline_spinner stop_inline_spinner
fi fi
# Clear trap
trap - INT TERM
# Cleanup temp files
rm -f "$temp_update" "$temp_upgrade"
if echo "$upgrade_output" | grep -q "already installed"; then if echo "$upgrade_output" | grep -q "already installed"; then
local installed_version local installed_version
installed_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') installed_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}')
@@ -71,12 +94,11 @@ update_via_homebrew() {
echo "" echo ""
fi fi
# Clear update cache # Clear update cache (suppress errors if cache doesn't exist or is locked)
rm -f "$HOME/.cache/mole/version_check" "$HOME/.cache/mole/update_message" 2> /dev/null || true rm -f "$HOME/.cache/mole/version_check" "$HOME/.cache/mole/update_message" 2> /dev/null || true
} }
# Remove apps from Dock # Remove applications from Dock
# Args: app paths to remove
remove_apps_from_dock() { remove_apps_from_dock() {
if [[ $# -eq 0 ]]; then if [[ $# -eq 0 ]]; then
return 0 return 0
@@ -89,7 +111,7 @@ remove_apps_from_dock() {
return 0 return 0
fi fi
# Execute Python helper to prune dock entries for the given app paths # Prune dock entries using Python helper
python3 - "$@" << 'PY' 2> /dev/null || return 0 python3 - "$@" << 'PY' 2> /dev/null || return 0
import os import os
import plistlib import plistlib

View File

@@ -29,11 +29,7 @@ fi
# Path Validation # Path Validation
# ============================================================================ # ============================================================================
# Validate path for deletion operations # Validate path for deletion (absolute, no traversal, not system dir)
# Checks: non-empty, absolute, no traversal, no control chars, not system dir
#
# Args: $1 - path to validate
# Returns: 0 if safe, 1 if unsafe
validate_path_for_deletion() { validate_path_for_deletion() {
local path="$1" local path="$1"
@@ -61,6 +57,13 @@ validate_path_for_deletion() {
return 1 return 1
fi fi
# Allow deletion of coresymbolicationd cache (safe system cache that can be rebuilt)
case "$path" in
/System/Library/Caches/com.apple.coresymbolicationd/data | /System/Library/Caches/com.apple.coresymbolicationd/data/*)
return 0
;;
esac
# Check path isn't critical system directory # Check path isn't critical system directory
case "$path" in case "$path" in
/ | /bin | /sbin | /usr | /usr/bin | /usr/sbin | /etc | /var | /System | /System/* | /Library/Extensions) / | /bin | /sbin | /usr | /usr/bin | /usr/sbin | /etc | /var | /System | /System/* | /Library/Extensions)
@@ -76,13 +79,7 @@ validate_path_for_deletion() {
# Safe Removal Operations # Safe Removal Operations
# ============================================================================ # ============================================================================
# Safe wrapper around rm -rf with path validation # Safe wrapper around rm -rf with validation
#
# Args:
# $1 - path to remove
# $2 - silent mode (optional, default: false)
#
# Returns: 0 on success, 1 on failure
safe_remove() { safe_remove() {
local path="$1" local path="$1"
local silent="${2:-false}" local silent="${2:-false}"
@@ -97,21 +94,37 @@ safe_remove() {
return 0 return 0
fi fi
# Dry-run mode: log but don't delete
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
debug_log "[DRY RUN] Would remove: $path"
return 0
fi
debug_log "Removing: $path" debug_log "Removing: $path"
# Perform the deletion # Perform the deletion
if rm -rf "$path" 2> /dev/null; then # SAFE: safe_remove implementation # Use || to capture the exit code so set -e won't abort on rm failures
local error_msg
local rm_exit=0
error_msg=$(rm -rf "$path" 2>&1) || rm_exit=$? # safe_remove
if [[ $rm_exit -eq 0 ]]; then
return 0 return 0
else else
[[ "$silent" != "true" ]] && log_error "Failed to remove: $path" # Check if it's a permission error
if [[ "$error_msg" == *"Permission denied"* ]] || [[ "$error_msg" == *"Operation not permitted"* ]]; then
MOLE_PERMISSION_DENIED_COUNT=${MOLE_PERMISSION_DENIED_COUNT:-0}
MOLE_PERMISSION_DENIED_COUNT=$((MOLE_PERMISSION_DENIED_COUNT + 1))
export MOLE_PERMISSION_DENIED_COUNT
debug_log "Permission denied: $path (may need Full Disk Access)"
else
[[ "$silent" != "true" ]] && log_error "Failed to remove: $path"
fi
return 1 return 1
fi fi
} }
# Safe sudo remove with additional symlink protection # Safe sudo removal with symlink protection
#
# Args: $1 - path to remove
# Returns: 0 on success, 1 on failure
safe_sudo_remove() { safe_sudo_remove() {
local path="$1" local path="$1"
@@ -132,6 +145,12 @@ safe_sudo_remove() {
return 1 return 1
fi fi
# Dry-run mode: log but don't delete
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
debug_log "[DRY RUN] Would remove (sudo): $path"
return 0
fi
debug_log "Removing (sudo): $path" debug_log "Removing (sudo): $path"
# Perform the deletion # Perform the deletion
@@ -147,15 +166,7 @@ safe_sudo_remove() {
# Safe Find and Delete Operations # Safe Find and Delete Operations
# ============================================================================ # ============================================================================
# Safe find delete with depth limit and validation # Safe file discovery and deletion with depth and age limits
#
# Args:
# $1 - base directory
# $2 - file pattern (e.g., "*.log")
# $3 - age in days (0 = all files, default: 7)
# $4 - type filter ("f" or "d", default: "f")
#
# Returns: 0 on success, 1 on failure
safe_find_delete() { safe_find_delete() {
local base_dir="$1" local base_dir="$1"
local pattern="$2" local pattern="$2"
@@ -181,44 +192,38 @@ safe_find_delete() {
debug_log "Finding in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)" debug_log "Finding in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)"
# Execute find with safety limits (maxdepth 5 covers most app cache structures) local find_args=("-maxdepth" "5" "-name" "$pattern" "-type" "$type_filter")
if [[ "$age_days" -eq 0 ]]; then if [[ "$age_days" -gt 0 ]]; then
# Delete all matching files without time restriction find_args+=("-mtime" "+$age_days")
command find "$base_dir" \
-maxdepth 5 \
-name "$pattern" \
-type "$type_filter" \
-delete 2> /dev/null || true
else
# Delete files older than age_days
command find "$base_dir" \
-maxdepth 5 \
-name "$pattern" \
-type "$type_filter" \
-mtime "+$age_days" \
-delete 2> /dev/null || true
fi fi
# Iterate results to respect should_protect_path when available
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
fi
safe_remove "$match" true || true
done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
return 0 return 0
} }
# Safe sudo find delete (same as safe_find_delete but with sudo) # Safe sudo discovery and deletion
#
# Args: same as safe_find_delete
# Returns: 0 on success, 1 on failure
safe_sudo_find_delete() { safe_sudo_find_delete() {
local base_dir="$1" local base_dir="$1"
local pattern="$2" local pattern="$2"
local age_days="${3:-7}" local age_days="${3:-7}"
local type_filter="${4:-f}" local type_filter="${4:-f}"
# Validate base directory # Validate base directory (use sudo for permission-restricted dirs)
if [[ ! -d "$base_dir" ]]; then if ! sudo test -d "$base_dir" 2> /dev/null; then
log_error "Directory does not exist: $base_dir" debug_log "Directory does not exist (skipping): $base_dir"
return 1 return 0
fi fi
if [[ -L "$base_dir" ]]; then if sudo test -L "$base_dir" 2> /dev/null; then
log_error "Refusing to search symlinked directory: $base_dir" log_error "Refusing to search symlinked directory: $base_dir"
return 1 return 1
fi fi
@@ -231,22 +236,21 @@ safe_sudo_find_delete() {
debug_log "Finding (sudo) in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)" debug_log "Finding (sudo) in $base_dir: $pattern (age: ${age_days}d, type: $type_filter)"
# Execute find with sudo local find_args=("-maxdepth" "5" "-name" "$pattern" "-type" "$type_filter")
if [[ "$age_days" -eq 0 ]]; then if [[ "$age_days" -gt 0 ]]; then
sudo find "$base_dir" \ find_args+=("-mtime" "+$age_days")
-maxdepth 5 \
-name "$pattern" \
-type "$type_filter" \
-delete 2> /dev/null || true
else
sudo find "$base_dir" \
-maxdepth 5 \
-name "$pattern" \
-type "$type_filter" \
-mtime "+$age_days" \
-delete 2> /dev/null || true
fi fi
# Iterate results to respect should_protect_path when available
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
fi
safe_sudo_remove "$match" || true
done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true)
return 0 return 0
} }
@@ -254,11 +258,7 @@ safe_sudo_find_delete() {
# Size Calculation # Size Calculation
# ============================================================================ # ============================================================================
# Get path size in kilobytes # Get path size in KB (returns 0 if not found)
# Uses timeout protection to prevent du from hanging on large directories
#
# Args: $1 - path
# Returns: size in KB (0 if path doesn't exist)
get_path_size_kb() { get_path_size_kb() {
local path="$1" local path="$1"
[[ -z "$path" || ! -e "$path" ]] && { [[ -z "$path" || ! -e "$path" ]] && {
@@ -266,15 +266,20 @@ get_path_size_kb() {
return return
} }
# Direct execution without timeout overhead - critical for performance in loops # Direct execution without timeout overhead - critical for performance in loops
# Use || echo 0 to ensure failure in du (e.g. permission error) doesn't exit script under set -e
# Pipefail would normally cause the pipeline to fail if du fails, but || handle catches it.
local size local size
size=$(command du -sk "$path" 2> /dev/null | awk '{print $1}') size=$(command du -sk "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true)
echo "${size:-0}"
# Ensure size is a valid number (fix for non-numeric du output)
if [[ "$size" =~ ^[0-9]+$ ]]; then
echo "$size"
else
echo "0"
fi
} }
# Calculate total size of multiple paths # Calculate total size for multiple paths
#
# Args: $1 - newline-separated list of paths
# Returns: total size in KB
calculate_total_size() { calculate_total_size() {
local files="$1" local files="$1"
local total_kb=0 local total_kb=0

View File

@@ -25,15 +25,14 @@ readonly LOG_FILE="${HOME}/.config/mole/mole.log"
readonly DEBUG_LOG_FILE="${HOME}/.config/mole/mole_debug_session.log" readonly DEBUG_LOG_FILE="${HOME}/.config/mole/mole_debug_session.log"
readonly LOG_MAX_SIZE_DEFAULT=1048576 # 1MB readonly LOG_MAX_SIZE_DEFAULT=1048576 # 1MB
# Ensure log directory exists # Ensure log directory and file exist with correct ownership
mkdir -p "$(dirname "$LOG_FILE")" 2> /dev/null || true ensure_user_file "$LOG_FILE"
# ============================================================================ # ============================================================================
# Log Rotation # Log Rotation
# ============================================================================ # ============================================================================
# Rotate log file if it exceeds max size # Rotate log file if it exceeds maximum size
# Called once at module load, not per log entry
rotate_log_once() { rotate_log_once() {
# Skip if already checked this session # Skip if already checked this session
[[ -n "${MOLE_LOG_ROTATED:-}" ]] && return 0 [[ -n "${MOLE_LOG_ROTATED:-}" ]] && return 0
@@ -42,7 +41,7 @@ rotate_log_once() {
local max_size="${MOLE_MAX_LOG_SIZE:-$LOG_MAX_SIZE_DEFAULT}" local max_size="${MOLE_MAX_LOG_SIZE:-$LOG_MAX_SIZE_DEFAULT}"
if [[ -f "$LOG_FILE" ]] && [[ $(get_file_size "$LOG_FILE") -gt "$max_size" ]]; then if [[ -f "$LOG_FILE" ]] && [[ $(get_file_size "$LOG_FILE") -gt "$max_size" ]]; then
mv "$LOG_FILE" "${LOG_FILE}.old" 2> /dev/null || true mv "$LOG_FILE" "${LOG_FILE}.old" 2> /dev/null || true
touch "$LOG_FILE" 2> /dev/null || true ensure_user_file "$LOG_FILE"
fi fi
} }
@@ -51,7 +50,6 @@ rotate_log_once() {
# ============================================================================ # ============================================================================
# Log informational message # Log informational message
# Args: $1 - message
log_info() { log_info() {
echo -e "${BLUE}$1${NC}" echo -e "${BLUE}$1${NC}"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S') local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
@@ -62,7 +60,6 @@ log_info() {
} }
# Log success message # Log success message
# Args: $1 - message
log_success() { log_success() {
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1" echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S') local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
@@ -73,7 +70,6 @@ log_success() {
} }
# Log warning message # Log warning message
# Args: $1 - message
log_warning() { log_warning() {
echo -e "${YELLOW}$1${NC}" echo -e "${YELLOW}$1${NC}"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S') local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
@@ -84,9 +80,8 @@ log_warning() {
} }
# Log error message # Log error message
# Args: $1 - message
log_error() { log_error() {
echo -e "${RED}${ICON_ERROR}${NC} $1" >&2 echo -e "${YELLOW}${ICON_ERROR}${NC} $1" >&2
local timestamp=$(date '+%Y-%m-%d %H:%M:%S') local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true echo "[$timestamp] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true
if [[ "${MO_DEBUG:-}" == "1" ]]; then if [[ "${MO_DEBUG:-}" == "1" ]]; then
@@ -94,8 +89,7 @@ log_error() {
fi fi
} }
# Debug logging - only shown when MO_DEBUG=1 # Debug logging (active when MO_DEBUG=1)
# Args: $@ - debug message components
debug_log() { debug_log() {
if [[ "${MO_DEBUG:-}" == "1" ]]; then if [[ "${MO_DEBUG:-}" == "1" ]]; then
echo -e "${GRAY}[DEBUG]${NC} $*" >&2 echo -e "${GRAY}[DEBUG]${NC} $*" >&2
@@ -110,6 +104,7 @@ log_system_info() {
export MOLE_SYS_INFO_LOGGED=1 export MOLE_SYS_INFO_LOGGED=1
# Reset debug log file for this new session # Reset debug log file for this new session
ensure_user_file "$DEBUG_LOG_FILE"
: > "$DEBUG_LOG_FILE" : > "$DEBUG_LOG_FILE"
# Start block in debug log file # Start block in debug log file
@@ -143,15 +138,12 @@ log_system_info() {
# Command Execution Wrappers # Command Execution Wrappers
# ============================================================================ # ============================================================================
# Run command silently, ignore errors # Run command silently (ignore errors)
# Args: $@ - command and arguments
run_silent() { run_silent() {
"$@" > /dev/null 2>&1 || true "$@" > /dev/null 2>&1 || true
} }
# Run command with error logging # Run command with error logging
# Args: $@ - command and arguments
# Returns: command exit code
run_logged() { run_logged() {
local cmd="$1" local cmd="$1"
# Log to main file, and also to debug file if enabled # Log to main file, and also to debug file if enabled
@@ -173,8 +165,7 @@ run_logged() {
# Formatted Output # Formatted Output
# ============================================================================ # ============================================================================
# Print formatted summary block with heading and details # Print formatted summary block
# Args: $1=status (ignored), $2=heading, $@=details
print_summary_block() { print_summary_block() {
local heading="" local heading=""
local -a details=() local -a details=()

View File

@@ -16,6 +16,7 @@ check_touchid_support() {
return 1 return 1
} }
# Detect clamshell mode (lid closed)
is_clamshell_mode() { is_clamshell_mode() {
# ioreg is missing (not macOS) -> treat as lid open # ioreg is missing (not macOS) -> treat as lid open
if ! command -v ioreg > /dev/null 2>&1; then if ! command -v ioreg > /dev/null 2>&1; then
@@ -115,15 +116,23 @@ request_sudo_access() {
# Check if in clamshell mode - if yes, skip Touch ID entirely # Check if in clamshell mode - if yes, skip Touch ID entirely
if is_clamshell_mode; then if is_clamshell_mode; then
echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg}" echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg}"
_request_password "$tty_path" if _request_password "$tty_path"; then
return $? # Clear all prompt lines (use safe clearing method)
safe_clear_lines 3 "$tty_path"
return 0
fi
return 1
fi fi
# Not in clamshell mode - try Touch ID if configured # Not in clamshell mode - try Touch ID if configured
if ! check_touchid_support; then if ! check_touchid_support; then
echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg}" echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg}"
_request_password "$tty_path" if _request_password "$tty_path"; then
return $? # Clear all prompt lines (use safe clearing method)
safe_clear_lines 3 "$tty_path"
return 0
fi
return 1
fi fi
# Touch ID is available and not in clamshell mode # Touch ID is available and not in clamshell mode
@@ -142,7 +151,8 @@ request_sudo_access() {
wait "$sudo_pid" 2> /dev/null wait "$sudo_pid" 2> /dev/null
local exit_code=$? local exit_code=$?
if [[ $exit_code -eq 0 ]] && sudo -n true 2> /dev/null; then if [[ $exit_code -eq 0 ]] && sudo -n true 2> /dev/null; then
# Touch ID succeeded # Touch ID succeeded - clear the prompt line
safe_clear_lines 1 "$tty_path"
return 0 return 0
fi fi
# Touch ID failed or cancelled # Touch ID failed or cancelled
@@ -168,10 +178,15 @@ request_sudo_access() {
sleep 1 sleep 1
# Clear any leftover prompts on the screen # Clear any leftover prompts on the screen
printf "\r\033[2K" > "$tty_path" safe_clear_line "$tty_path"
# Now use our password input (this should not trigger Touch ID again) # Now use our password input (this should not trigger Touch ID again)
_request_password "$tty_path" if _request_password "$tty_path"; then
# Clear all prompt lines (use safe clearing method)
safe_clear_lines 3 "$tty_path"
return 0
fi
return 1
} }
# ============================================================================ # ============================================================================
@@ -182,8 +197,7 @@ request_sudo_access() {
MOLE_SUDO_KEEPALIVE_PID="" MOLE_SUDO_KEEPALIVE_PID=""
MOLE_SUDO_ESTABLISHED="false" MOLE_SUDO_ESTABLISHED="false"
# Start sudo keepalive background process # Start sudo keepalive
# Returns: PID of keepalive process
_start_sudo_keepalive() { _start_sudo_keepalive() {
# Start background keepalive process with all outputs redirected # Start background keepalive process with all outputs redirected
# This is critical: command substitution waits for all file descriptors to close # This is critical: command substitution waits for all file descriptors to close
@@ -212,8 +226,7 @@ _start_sudo_keepalive() {
echo $pid echo $pid
} }
# Stop sudo keepalive process # Stop sudo keepalive
# Args: $1 - PID of keepalive process
_stop_sudo_keepalive() { _stop_sudo_keepalive() {
local pid="${1:-}" local pid="${1:-}"
if [[ -n "$pid" ]]; then if [[ -n "$pid" ]]; then
@@ -227,8 +240,7 @@ has_sudo_session() {
sudo -n true 2> /dev/null sudo -n true 2> /dev/null
} }
# Request sudo access (wrapper for common.sh function) # Request administrative access
# Args: $1 - prompt message
request_sudo() { request_sudo() {
local prompt_msg="${1:-Admin access required}" local prompt_msg="${1:-Admin access required}"
@@ -244,8 +256,7 @@ request_sudo() {
fi fi
} }
# Ensure sudo session is established with keepalive # Maintain active sudo session with keepalive
# Args: $1 - prompt message
ensure_sudo_session() { ensure_sudo_session() {
local prompt="${1:-Admin access required}" local prompt="${1:-Admin access required}"
@@ -287,8 +298,7 @@ register_sudo_cleanup() {
trap stop_sudo_session EXIT INT TERM trap stop_sudo_session EXIT INT TERM
} }
# Check if sudo is likely needed for given operations # Predict if operation requires administrative access
# Args: $@ - list of operations to check
will_need_sudo() { will_need_sudo() {
local -a operations=("$@") local -a operations=("$@")
for op in "${operations[@]}"; do for op in "${operations[@]}"; do

View File

@@ -17,10 +17,7 @@ clear_screen() { printf '\033[2J\033[H'; }
hide_cursor() { [[ -t 1 ]] && printf '\033[?25l' >&2 || true; } hide_cursor() { [[ -t 1 ]] && printf '\033[?25l' >&2 || true; }
show_cursor() { [[ -t 1 ]] && printf '\033[?25h' >&2 || true; } show_cursor() { [[ -t 1 ]] && printf '\033[?25h' >&2 || true; }
# Calculate display width of a string (CJK characters count as 2) # Calculate display width (CJK characters count as 2)
# Args: $1 - string to measure
# Returns: display width
# Note: Works correctly even when LC_ALL=C is set
get_display_width() { get_display_width() {
local str="$1" local str="$1"
@@ -63,11 +60,26 @@ get_display_width() {
local padding=$((extra_bytes / 2)) local padding=$((extra_bytes / 2))
width=$((char_count + padding)) width=$((char_count + padding))
# Adjust for zero-width joiners and emoji variation selectors (common in filenames/emojis)
# These characters add bytes but no visible width; subtract their count if present.
local zwj=$'\u200d' # zero-width joiner
local vs16=$'\ufe0f' # emoji variation selector
local zero_width=0
local without_zwj=${str//$zwj/}
zero_width=$((zero_width + (char_count - ${#without_zwj})))
local without_vs=${str//$vs16/}
zero_width=$((zero_width + (char_count - ${#without_vs})))
if ((zero_width > 0 && width > zero_width)); then
width=$((width - zero_width))
fi
echo "$width" echo "$width"
} }
# Truncate string by display width (handles CJK correctly) # Truncate string by display width (handles CJK)
# Args: $1 - string, $2 - max display width
truncate_by_display_width() { truncate_by_display_width() {
local str="$1" local str="$1"
local max_width="$2" local max_width="$2"
@@ -140,7 +152,7 @@ truncate_by_display_width() {
echo "${truncated}..." echo "${truncated}..."
} }
# Keyboard input - read single keypress # Read single keyboard input
read_key() { read_key() {
local key rest read_status local key rest read_status
IFS= read -r -s -n 1 key IFS= read -r -s -n 1 key
@@ -222,7 +234,7 @@ drain_pending_input() {
done done
} }
# Menu display # Format menu option display
show_menu_option() { show_menu_option() {
local number="$1" local number="$1"
local text="$2" local text="$2"
@@ -235,53 +247,77 @@ show_menu_option() {
fi fi
} }
# Inline spinner # Background spinner implementation
INLINE_SPINNER_PID="" INLINE_SPINNER_PID=""
INLINE_SPINNER_STOP_FILE=""
start_inline_spinner() { start_inline_spinner() {
stop_inline_spinner 2> /dev/null || true stop_inline_spinner 2> /dev/null || true
local message="$1" local message="$1"
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
# Create unique stop flag file for this spinner instance
INLINE_SPINNER_STOP_FILE="${TMPDIR:-/tmp}/mole_spinner_$$_$RANDOM.stop"
( (
trap 'exit 0' TERM INT EXIT local stop_file="$INLINE_SPINNER_STOP_FILE"
local chars local chars
chars="$(mo_spinner_chars)" chars="$(mo_spinner_chars)"
[[ -z "$chars" ]] && chars="|/-\\" [[ -z "$chars" ]] && chars="|/-\\"
local i=0 local i=0
while true; do
# Cooperative exit: check for stop file instead of relying on signals
while [[ ! -f "$stop_file" ]]; do
local c="${chars:$((i % ${#chars})):1}" local c="${chars:$((i % ${#chars})):1}"
# Output to stderr to avoid interfering with stdout # Output to stderr to avoid interfering with stdout
printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$message" >&2 || exit 0 printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$message" >&2 || break
((i++)) ((i++))
sleep 0.1 sleep 0.1
done done
# Clean up stop file before exiting
rm -f "$stop_file" 2> /dev/null || true
exit 0
) & ) &
INLINE_SPINNER_PID=$! INLINE_SPINNER_PID=$!
disown 2> /dev/null || true disown 2> /dev/null || true
else else
echo -n " ${BLUE}|${NC} $message" >&2 echo -n " ${BLUE}|${NC} $message" >&2 || true
fi fi
} }
stop_inline_spinner() { stop_inline_spinner() {
if [[ -n "$INLINE_SPINNER_PID" ]]; then if [[ -n "$INLINE_SPINNER_PID" ]]; then
# Try graceful TERM first, then force KILL if needed # Cooperative stop: create stop file to signal spinner to exit
if kill -0 "$INLINE_SPINNER_PID" 2> /dev/null; then if [[ -n "$INLINE_SPINNER_STOP_FILE" ]]; then
kill -TERM "$INLINE_SPINNER_PID" 2> /dev/null || true touch "$INLINE_SPINNER_STOP_FILE" 2> /dev/null || true
sleep 0.05 2> /dev/null || true
# Force kill if still running
if kill -0 "$INLINE_SPINNER_PID" 2> /dev/null; then
kill -KILL "$INLINE_SPINNER_PID" 2> /dev/null || true
fi
fi fi
# Wait briefly for cooperative exit
local wait_count=0
while kill -0 "$INLINE_SPINNER_PID" 2> /dev/null && [[ $wait_count -lt 5 ]]; do
sleep 0.05 2> /dev/null || true
((wait_count++))
done
# Only use SIGKILL as last resort if process is stuck
if kill -0 "$INLINE_SPINNER_PID" 2> /dev/null; then
kill -KILL "$INLINE_SPINNER_PID" 2> /dev/null || true
fi
wait "$INLINE_SPINNER_PID" 2> /dev/null || true wait "$INLINE_SPINNER_PID" 2> /dev/null || true
# Cleanup
rm -f "$INLINE_SPINNER_STOP_FILE" 2> /dev/null || true
INLINE_SPINNER_PID="" INLINE_SPINNER_PID=""
INLINE_SPINNER_STOP_FILE=""
# Clear the line - use \033[2K to clear entire line, not just to end # Clear the line - use \033[2K to clear entire line, not just to end
[[ -t 1 ]] && printf "\r\033[2K" >&2 [[ -t 1 ]] && printf "\r\033[2K" >&2 || true
fi fi
} }
# Wrapper for running commands with spinner # Run command with a terminal spinner
with_spinner() { with_spinner() {
local msg="$1" local msg="$1"
shift || true shift || true
@@ -302,9 +338,7 @@ mo_spinner_chars() {
printf "%s" "$chars" printf "%s" "$chars"
} }
# Format last used time for display # Format relative time for compact display (e.g., 3d ago)
# Args: $1 = last used string (e.g., "3 days ago", "Today", "Never")
# Returns: Compact version (e.g., "3d ago", "Today", "Never")
format_last_used_summary() { format_last_used_summary() {
local value="$1" local value="$1"
@@ -341,3 +375,60 @@ format_last_used_summary() {
fi fi
echo "$value" echo "$value"
} }
# Check if terminal has Full Disk Access
# Returns 0 if FDA is granted, 1 if denied, 2 if unknown
has_full_disk_access() {
# Cache the result to avoid repeated checks
if [[ -n "${MOLE_HAS_FDA:-}" ]]; then
if [[ "$MOLE_HAS_FDA" == "1" ]]; then
return 0
elif [[ "$MOLE_HAS_FDA" == "unknown" ]]; then
return 2
else
return 1
fi
fi
# Test access to protected directories that require FDA
# Strategy: Try to access directories that are commonly protected
# If ANY of them are accessible, we likely have FDA
# If ALL fail, we definitely don't have FDA
local -a protected_dirs=(
"$HOME/Library/Safari/LocalStorage"
"$HOME/Library/Mail/V10"
"$HOME/Library/Messages/chat.db"
)
local accessible_count=0
local tested_count=0
for test_path in "${protected_dirs[@]}"; do
# Only test when the protected path exists
if [[ -e "$test_path" ]]; then
tested_count=$((tested_count + 1))
# Try to stat the ACTUAL protected path - this requires FDA
if stat "$test_path" > /dev/null 2>&1; then
accessible_count=$((accessible_count + 1))
fi
fi
done
# Three possible outcomes:
# 1. tested_count = 0: Can't determine (test paths don't exist) → unknown
# 2. tested_count > 0 && accessible_count > 0: Has FDA → yes
# 3. tested_count > 0 && accessible_count = 0: No FDA → no
if [[ $tested_count -eq 0 ]]; then
# Can't determine - test paths don't exist, treat as unknown
export MOLE_HAS_FDA="unknown"
return 2
elif [[ $accessible_count -gt 0 ]]; then
# At least one path is accessible → has FDA
export MOLE_HAS_FDA=1
return 0
else
# Tested paths exist but not accessible → no FDA
export MOLE_HAS_FDA=0
return 1
fi
}

View File

@@ -10,9 +10,13 @@ show_suggestions() {
local can_auto_fix=false local can_auto_fix=false
local -a auto_fix_items=() local -a auto_fix_items=()
local -a manual_items=() local -a manual_items=()
local skip_security_autofix=false
if [[ "${MOLE_SECURITY_FIXES_SHOWN:-}" == "true" ]]; then
skip_security_autofix=true
fi
# Security suggestions # Security suggestions
if [[ -n "${FIREWALL_DISABLED:-}" && "${FIREWALL_DISABLED}" == "true" ]]; then if [[ "$skip_security_autofix" == "false" && -n "${FIREWALL_DISABLED:-}" && "${FIREWALL_DISABLED}" == "true" ]]; then
auto_fix_items+=("Enable Firewall for better security") auto_fix_items+=("Enable Firewall for better security")
has_suggestions=true has_suggestions=true
can_auto_fix=true can_auto_fix=true
@@ -24,7 +28,7 @@ show_suggestions() {
fi fi
# Configuration suggestions # Configuration suggestions
if [[ -n "${TOUCHID_NOT_CONFIGURED:-}" && "${TOUCHID_NOT_CONFIGURED}" == "true" ]]; then if [[ "$skip_security_autofix" == "false" && -n "${TOUCHID_NOT_CONFIGURED:-}" && "${TOUCHID_NOT_CONFIGURED}" == "true" ]]; then
auto_fix_items+=("Enable Touch ID for sudo") auto_fix_items+=("Enable Touch ID for sudo")
has_suggestions=true has_suggestions=true
can_auto_fix=true can_auto_fix=true
@@ -94,7 +98,7 @@ ask_for_auto_fix() {
return 1 return 1
fi fi
echo -ne "${PURPLE}${ICON_ARROW}${NC} Auto-fix issues now? ${GRAY}Enter confirm / ESC cancel${NC}: " echo -ne "${PURPLE}${ICON_ARROW}${NC} Auto-fix issues now? ${GRAY}Enter confirm / Space cancel${NC}: "
local key local key
if ! key=$(read_key); then if ! key=$(read_key); then
@@ -132,7 +136,7 @@ perform_auto_fix() {
# Fix Firewall # Fix Firewall
if [[ -n "${FIREWALL_DISABLED:-}" && "${FIREWALL_DISABLED}" == "true" ]]; then if [[ -n "${FIREWALL_DISABLED:-}" && "${FIREWALL_DISABLED}" == "true" ]]; then
echo -e "${BLUE}Enabling Firewall...${NC}" echo -e "${BLUE}Enabling Firewall...${NC}"
if sudo defaults write /Library/Preferences/com.apple.alf globalstate -int 1 2> /dev/null; then if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then
echo -e "${GREEN}${NC} Firewall enabled" echo -e "${GREEN}${NC} Firewall enabled"
((fixed_count++)) ((fixed_count++))
fixed_items+=("Firewall enabled") fixed_items+=("Firewall enabled")

117
lib/manage/purge_paths.sh Normal file
View File

@@ -0,0 +1,117 @@
#!/bin/bash
# Purge paths management functionality
# Opens config file for editing and shows current status
set -euo pipefail
# Get script directory and source dependencies
_MOLE_MANAGE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$_MOLE_MANAGE_DIR/../core/common.sh"
# Only source project.sh if not already loaded (has readonly vars)
if [[ -z "${PURGE_TARGETS:-}" ]]; then
source "$_MOLE_MANAGE_DIR/../clean/project.sh"
fi
# Config file path (use :- to avoid re-declaration if already set)
PURGE_PATHS_CONFIG="${PURGE_PATHS_CONFIG:-$HOME/.config/mole/purge_paths}"
# Ensure config file exists with helpful template
ensure_config_template() {
if [[ ! -f "$PURGE_PATHS_CONFIG" ]]; then
ensure_user_dir "$(dirname "$PURGE_PATHS_CONFIG")"
cat > "$PURGE_PATHS_CONFIG" << 'EOF'
# Mole Purge Paths - Directories to scan for project artifacts
# Add one path per line (supports ~ for home directory)
# Delete all paths or this file to use defaults
#
# Example:
# ~/Documents/MyProjects
# ~/Work/ClientA
# ~/Work/ClientB
EOF
fi
}
# Main management function
manage_purge_paths() {
ensure_config_template
local display_config="${PURGE_PATHS_CONFIG/#$HOME/~}"
# Clear screen
if [[ -t 1 ]]; then
printf '\033[2J\033[H'
fi
echo -e "${PURPLE_BOLD}Purge Paths Configuration${NC}"
echo ""
# Show current status
echo -e "${YELLOW}Current Scan Paths:${NC}"
# Reload config
load_purge_config
if [[ ${#PURGE_SEARCH_PATHS[@]} -gt 0 ]]; then
for path in "${PURGE_SEARCH_PATHS[@]}"; do
local display_path="${path/#$HOME/~}"
if [[ -d "$path" ]]; then
echo -e " ${GREEN}${NC} $display_path"
else
echo -e " ${GRAY}${NC} $display_path ${GRAY}(not found)${NC}"
fi
done
fi
# Check if using custom config
local custom_count=0
if [[ -f "$PURGE_PATHS_CONFIG" ]]; then
while IFS= read -r line; do
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" || "$line" =~ ^# ]] && continue
((custom_count++))
done < "$PURGE_PATHS_CONFIG"
fi
echo ""
if [[ $custom_count -gt 0 ]]; then
echo -e "${GRAY}Using custom config with $custom_count path(s)${NC}"
else
echo -e "${GRAY}Using ${#DEFAULT_PURGE_SEARCH_PATHS[@]} default paths${NC}"
fi
echo ""
echo -e "${YELLOW}Default Paths:${NC}"
for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do
echo -e " ${GRAY}-${NC} ${path/#$HOME/~}"
done
echo ""
echo -e "${YELLOW}Config File:${NC} $display_config"
echo ""
# Open in editor
local editor="${EDITOR:-${VISUAL:-vim}}"
echo -e "Opening in ${CYAN}$editor${NC}..."
echo -e "${GRAY}Save and exit to apply changes. Leave empty to use defaults.${NC}"
echo ""
# Wait for user to read
sleep 1
# Open editor
"$editor" "$PURGE_PATHS_CONFIG"
# Reload and show updated status
load_purge_config
echo ""
echo -e "${GREEN}${ICON_SUCCESS}${NC} Configuration updated"
echo -e "${GRAY}Run 'mo purge' to clean with new paths${NC}"
echo ""
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
manage_purge_paths
fi

View File

@@ -76,9 +76,9 @@ ask_for_updates() {
echo -e "$item" echo -e "$item"
done done
echo "" echo ""
# If only Mole is relevant for automation, prompt just for Mole
# If Mole has updates, offer to update it
if [[ "${MOLE_UPDATE_AVAILABLE:-}" == "true" ]]; then if [[ "${MOLE_UPDATE_AVAILABLE:-}" == "true" ]]; then
echo ""
echo -ne "${YELLOW}Update Mole now?${NC} ${GRAY}Enter confirm / ESC cancel${NC}: " echo -ne "${YELLOW}Update Mole now?${NC} ${GRAY}Enter confirm / ESC cancel${NC}: "
local key local key
@@ -92,55 +92,33 @@ ask_for_updates() {
echo "yes" echo "yes"
echo "" echo ""
return 0 return 0
else
echo "skip"
echo ""
return 1
fi fi
fi fi
# For other updates, just show instructions
# (Mole update check above handles the return 0 case, so we only get here if no Mole update)
if [[ "${BREW_OUTDATED_COUNT:-0}" -gt 0 ]]; then
echo -e "${YELLOW}Tip:${NC} Run ${GREEN}brew upgrade${NC} to update Homebrew packages"
fi
if [[ "${APPSTORE_UPDATE_COUNT:-0}" -gt 0 ]]; then
echo -e "${YELLOW}Tip:${NC} Open ${BLUE}App Store${NC} to update apps"
fi
if [[ "${MACOS_UPDATE_AVAILABLE:-}" == "true" ]]; then
echo -e "${YELLOW}Tip:${NC} Open ${BLUE}System Settings${NC} to update macOS"
fi
echo "" 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"
return 1 return 1
} }
# Perform all pending updates # Perform all pending updates
# Returns: 0 if all succeeded, 1 if some failed # Returns: 0 if all succeeded, 1 if some failed
perform_updates() { perform_updates() {
# Only handle Mole updates here # Only handle Mole updates here; Homebrew/App Store/macOS are manual (tips shown in ask_for_updates)
# Other updates are now informational-only in ask_for_updates
local updated_count=0 local updated_count=0
local total_count=0
# Update Mole
if [[ -n "${MOLE_UPDATE_AVAILABLE:-}" && "${MOLE_UPDATE_AVAILABLE}" == "true" ]]; then if [[ -n "${MOLE_UPDATE_AVAILABLE:-}" && "${MOLE_UPDATE_AVAILABLE}" == "true" ]]; then
echo -e "${BLUE}Updating Mole...${NC}" echo -e "${BLUE}Updating Mole...${NC}"
# Try to find mole executable
local mole_bin="${SCRIPT_DIR}/../../mole" local mole_bin="${SCRIPT_DIR}/../../mole"
[[ ! -f "$mole_bin" ]] && mole_bin=$(command -v mole 2> /dev/null || echo "") [[ ! -f "$mole_bin" ]] && mole_bin=$(command -v mole 2> /dev/null || echo "")
if [[ -x "$mole_bin" ]]; then if [[ -x "$mole_bin" ]]; then
# We use exec here or just run it?
# If we run 'mole update', it replaces the script.
# Since this function is part of a sourced script, replacing the file on disk is risky while running.
# However, 'mole update' script usually handles this by downloading to a temp file and moving it.
# But the shell might not like the file changing under it.
# The original code ran it this way, so we assume it's safe enough or handled by mole update implementation.
if "$mole_bin" update 2>&1 | grep -qE "(Updated|latest version)"; then if "$mole_bin" update 2>&1 | grep -qE "(Updated|latest version)"; then
echo -e "${GREEN}${NC} Mole updated" echo -e "${GREEN}${NC} Mole updated"
reset_mole_cache reset_mole_cache
updated_count=1 ((updated_count++))
else else
echo -e "${RED}${NC} Mole update failed" echo -e "${RED}${NC} Mole update failed"
fi fi
@@ -148,11 +126,17 @@ perform_updates() {
echo -e "${RED}${NC} Mole executable not found" echo -e "${RED}${NC} Mole executable not found"
fi fi
echo "" echo ""
total_count=1
fi fi
if [[ $updated_count -gt 0 ]]; then if [[ $total_count -eq 0 ]]; then
echo -e "${GRAY}No updates to perform${NC}"
return 0
elif [[ $updated_count -eq $total_count ]]; then
echo -e "${GREEN}All updates completed (${updated_count}/${total_count})${NC}"
return 0 return 0
else else
echo -e "${RED}Update failed (${updated_count}/${total_count})${NC}"
return 1 return 1
fi fi
} }

View File

@@ -44,7 +44,7 @@ save_whitelist_patterns() {
header_text="# Mole Whitelist - Protected paths won't be deleted\n# Default protections: Playwright browsers, HuggingFace models, Maven repo, Ollama models, Surge Mac, R renv, Finder metadata\n# Add one pattern per line to keep items safe." header_text="# Mole Whitelist - Protected paths won't be deleted\n# Default protections: Playwright browsers, HuggingFace models, Maven repo, Ollama models, Surge Mac, R renv, Finder metadata\n# Add one pattern per line to keep items safe."
fi fi
mkdir -p "$(dirname "$config_file")" ensure_user_file "$config_file"
echo -e "$header_text" > "$config_file" echo -e "$header_text" > "$config_file"
@@ -81,6 +81,7 @@ Apple Mail cache|$HOME/Library/Caches/com.apple.mail/*|system_cache
Gradle build cache (Android Studio, Gradle projects)|$HOME/.gradle/caches/*|ide_cache Gradle build cache (Android Studio, Gradle projects)|$HOME/.gradle/caches/*|ide_cache
Gradle daemon processes cache|$HOME/.gradle/daemon/*|ide_cache Gradle daemon processes cache|$HOME/.gradle/daemon/*|ide_cache
Xcode DerivedData (build outputs, indexes)|$HOME/Library/Developer/Xcode/DerivedData/*|ide_cache Xcode DerivedData (build outputs, indexes)|$HOME/Library/Developer/Xcode/DerivedData/*|ide_cache
Xcode archives (built app packages)|$HOME/Library/Developer/Xcode/Archives/*|ide_cache
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
@@ -143,6 +144,7 @@ Podman container cache|$HOME/.local/share/containers/cache/*|container_cache
Font cache|$HOME/Library/Caches/com.apple.FontRegistry/*|system_cache Font cache|$HOME/Library/Caches/com.apple.FontRegistry/*|system_cache
Spotlight metadata cache|$HOME/Library/Caches/com.apple.spotlight/*|system_cache Spotlight metadata cache|$HOME/Library/Caches/com.apple.spotlight/*|system_cache
CloudKit cache|$HOME/Library/Caches/CloudKit/*|system_cache CloudKit cache|$HOME/Library/Caches/CloudKit/*|system_cache
Trash|$HOME/.Trash|system_cache
EOF EOF
# Add FINDER_METADATA with constant reference # Add FINDER_METADATA with constant reference
echo "Finder metadata (.DS_Store)|$FINDER_METADATA_SENTINEL|system_cache" echo "Finder metadata (.DS_Store)|$FINDER_METADATA_SENTINEL|system_cache"
@@ -154,8 +156,8 @@ get_optimize_whitelist_items() {
cat << 'EOF' cat << 'EOF'
macOS Firewall check|firewall|security_check macOS Firewall check|firewall|security_check
Gatekeeper check|gatekeeper|security_check Gatekeeper check|gatekeeper|security_check
Homebrew updates check|check_brew_updates|update_check
macOS system updates check|check_macos_updates|update_check macOS system updates check|check_macos_updates|update_check
Mole updates check|check_mole_update|update_check
Homebrew health check (doctor)|check_brew_health|health_check Homebrew health check (doctor)|check_brew_health|health_check
SIP status check|check_sip|security_check SIP status check|check_sip|security_check
FileVault status check|check_filevault|security_check FileVault status check|check_filevault|security_check
@@ -163,7 +165,6 @@ TouchID sudo check|check_touchid|config_check
Rosetta 2 check|check_rosetta|config_check Rosetta 2 check|check_rosetta|config_check
Git configuration check|check_git_config|config_check Git configuration check|check_git_config|config_check
Login items check|check_login_items|config_check Login items check|check_login_items|config_check
Spotlight cache cleanup|spotlight_cache|system_optimization
EOF EOF
} }
@@ -280,12 +281,16 @@ manage_whitelist_categories() {
if [[ "$mode" == "optimize" ]]; then if [[ "$mode" == "optimize" ]]; then
items_source=$(get_optimize_whitelist_items) items_source=$(get_optimize_whitelist_items)
menu_title="Whitelist Manager Select system checks to ignore"
active_config_file="$WHITELIST_CONFIG_OPTIMIZE" active_config_file="$WHITELIST_CONFIG_OPTIMIZE"
local display_config="${active_config_file/#$HOME/~}"
menu_title="Whitelist Manager Select system checks to ignore
${GRAY}Edit: ${display_config}${NC}"
else else
items_source=$(get_all_cache_items) items_source=$(get_all_cache_items)
menu_title="Whitelist Manager Select caches to protect"
active_config_file="$WHITELIST_CONFIG_CLEAN" active_config_file="$WHITELIST_CONFIG_CLEAN"
local display_config="${active_config_file/#$HOME/~}"
menu_title="Whitelist Manager Select caches to protect
${GRAY}Edit: ${display_config}${NC}"
fi fi
while IFS='|' read -r display_name pattern _; do while IFS='|' read -r display_name pattern _; do
@@ -366,9 +371,8 @@ manage_whitelist_categories() {
unset MOLE_PRESELECTED_INDICES unset MOLE_PRESELECTED_INDICES
local exit_code=$? local exit_code=$?
# Normal exit or cancel
if [[ $exit_code -ne 0 ]]; then if [[ $exit_code -ne 0 ]]; then
echo ""
echo -e "${YELLOW}Cancelled${NC}"
return 1 return 1
fi fi
@@ -413,7 +417,8 @@ manage_whitelist_categories() {
else else
summary_lines+=("Protected ${total_protected} cache(s)") summary_lines+=("Protected ${total_protected} cache(s)")
fi fi
summary_lines+=("Saved to ${active_config_file}") local display_config="${active_config_file/#$HOME/~}"
summary_lines+=("Config: ${GRAY}${display_config}${NC}")
print_summary_block "${summary_lines[@]}" print_summary_block "${summary_lines[@]}"
printf '\n' printf '\n'

View File

@@ -1,28 +1,19 @@
#!/bin/bash #!/bin/bash
# System Configuration Maintenance Module # System Configuration Maintenance Module.
# Fix broken preferences and broken login items # Fix broken preferences and login items.
set -euo pipefail set -euo pipefail
# ============================================================================ # Remove corrupted preference files.
# Broken Preferences Detection and Cleanup
# Find and remove corrupted .plist files
# ============================================================================
# Clean broken preference files
# Uses plutil -lint to validate plist files
# Returns: count of broken files fixed
fix_broken_preferences() { fix_broken_preferences() {
local prefs_dir="$HOME/Library/Preferences" local prefs_dir="$HOME/Library/Preferences"
[[ -d "$prefs_dir" ]] || return 0 [[ -d "$prefs_dir" ]] || return 0
local broken_count=0 local broken_count=0
# Check main preferences directory
while IFS= read -r plist_file; do while IFS= read -r plist_file; do
[[ -f "$plist_file" ]] || continue [[ -f "$plist_file" ]] || continue
# Skip system preferences
local filename local filename
filename=$(basename "$plist_file") filename=$(basename "$plist_file")
case "$filename" in case "$filename" in
@@ -31,15 +22,13 @@ fix_broken_preferences() {
;; ;;
esac esac
# Validate plist using plutil
plutil -lint "$plist_file" > /dev/null 2>&1 && continue plutil -lint "$plist_file" > /dev/null 2>&1 && continue
# Remove broken plist safe_remove "$plist_file" true > /dev/null 2>&1 || true
rm -f "$plist_file" 2> /dev/null || true
((broken_count++)) ((broken_count++))
done < <(command find "$prefs_dir" -maxdepth 1 -name "*.plist" -type f 2> /dev/null || true) done < <(command find "$prefs_dir" -maxdepth 1 -name "*.plist" -type f 2> /dev/null || true)
# Check ByHost preferences with timeout protection # Check ByHost preferences.
local byhost_dir="$prefs_dir/ByHost" local byhost_dir="$prefs_dir/ByHost"
if [[ -d "$byhost_dir" ]]; then if [[ -d "$byhost_dir" ]]; then
while IFS= read -r plist_file; do while IFS= read -r plist_file; do
@@ -55,63 +44,10 @@ fix_broken_preferences() {
plutil -lint "$plist_file" > /dev/null 2>&1 && continue plutil -lint "$plist_file" > /dev/null 2>&1 && continue
rm -f "$plist_file" 2> /dev/null || true safe_remove "$plist_file" true > /dev/null 2>&1 || true
((broken_count++)) ((broken_count++))
done < <(command find "$byhost_dir" -name "*.plist" -type f 2> /dev/null || true) done < <(command find "$byhost_dir" -name "*.plist" -type f 2> /dev/null || true)
fi fi
echo "$broken_count" echo "$broken_count"
} }
# ============================================================================
# Broken Login Items Cleanup
# Find and remove login items pointing to non-existent files
# ============================================================================
# Clean broken login items (LaunchAgents pointing to missing executables)
# Returns: count of broken items fixed
fix_broken_login_items() {
local launch_agents_dir="$HOME/Library/LaunchAgents"
[[ -d "$launch_agents_dir" ]] || return 0
# Check whitelist
if command -v is_whitelisted > /dev/null && is_whitelisted "check_login_items"; then return 0; fi
local broken_count=0
while IFS= read -r plist_file; do
[[ -f "$plist_file" ]] || continue
# Skip system items
local filename
filename=$(basename "$plist_file")
case "$filename" in
com.apple.*)
continue
;;
esac
# Extract Program or ProgramArguments[0] from plist using plutil
local program=""
program=$(plutil -extract Program raw "$plist_file" 2> /dev/null || echo "")
if [[ -z "$program" ]]; then
# Try ProgramArguments array (first element)
program=$(plutil -extract ProgramArguments.0 raw "$plist_file" 2> /dev/null || echo "")
fi
# Expand tilde in path if present
program="${program/#\~/$HOME}"
# Skip if no program found or program exists
[[ -z "$program" ]] && continue
[[ -e "$program" ]] && continue
# Program doesn't exist - this is a broken login item
launchctl unload "$plist_file" 2> /dev/null || true
rm -f "$plist_file" 2> /dev/null || true
((broken_count++))
done < <(command find "$launch_agents_dir" -name "*.plist" -type f 2> /dev/null || true)
echo "$broken_count"
}

File diff suppressed because it is too large Load Diff

View File

@@ -18,14 +18,33 @@ format_app_display() {
[[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]] && size_str="$size" [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]] && size_str="$size"
# Calculate available width for app name based on terminal width # Calculate available width for app name based on terminal width
# use passed width or calculate it (but calculation is slow in loops) # Accept pre-calculated max_name_width (5th param) to avoid recalculation in loops
local terminal_width="${4:-$(tput cols 2> /dev/null || echo 80)}" local terminal_width="${4:-$(tput cols 2> /dev/null || echo 80)}"
local fixed_width=28 local max_name_width="${5:-}"
local available_width=$((terminal_width - fixed_width)) local available_width
# Set reasonable bounds for name width: 24-35 display width if [[ -n "$max_name_width" ]]; then
[[ $available_width -lt 24 ]] && available_width=24 # Use pre-calculated width from caller
[[ $available_width -gt 35 ]] && available_width=35 available_width=$max_name_width
else
# Fallback: calculate it (slower, but works for standalone calls)
# Fixed elements: " ○ " (4) + " " (1) + size (9) + " | " (3) + max_last (7) = 24
local fixed_width=24
available_width=$((terminal_width - fixed_width))
# Dynamic minimum for better spacing on wide terminals
local min_width=18
if [[ $terminal_width -ge 120 ]]; then
min_width=48
elif [[ $terminal_width -ge 100 ]]; then
min_width=38
elif [[ $terminal_width -ge 80 ]]; then
min_width=25
fi
[[ $available_width -lt $min_width ]] && available_width=$min_width
[[ $available_width -gt 60 ]] && available_width=60
fi
# Truncate long names if needed (based on display width, not char count) # Truncate long names if needed (based on display width, not char count)
local truncated_name local truncated_name
@@ -66,6 +85,31 @@ select_apps_for_uninstall() {
fi fi
fi fi
# Pre-scan to get actual max name width
local max_name_width=0
for app_data in "${apps_data[@]}"; do
IFS='|' read -r _ _ display_name _ _ _ _ <<< "$app_data"
local name_width=$(get_display_width "$display_name")
[[ $name_width -gt $max_name_width ]] && max_name_width=$name_width
done
# Constrain based on terminal width: fixed=24, min varies by terminal width, max=60
local fixed_width=24
local available=$((terminal_width - fixed_width))
# Dynamic minimum: wider terminals get larger minimum for better spacing
local min_width=18
if [[ $terminal_width -ge 120 ]]; then
min_width=48 # Wide terminals: very generous spacing
elif [[ $terminal_width -ge 100 ]]; then
min_width=38 # Medium-wide terminals: generous spacing
elif [[ $terminal_width -ge 80 ]]; then
min_width=25 # Standard terminals
fi
[[ $max_name_width -lt $min_width ]] && max_name_width=$min_width
[[ $available -lt $max_name_width ]] && max_name_width=$available
[[ $max_name_width -gt 60 ]] && max_name_width=60
local -a menu_options=() local -a menu_options=()
# Prepare metadata (comma-separated) for sorting/filtering inside the menu # Prepare metadata (comma-separated) for sorting/filtering inside the menu
local epochs_csv="" local epochs_csv=""
@@ -74,7 +118,7 @@ select_apps_for_uninstall() {
for app_data in "${apps_data[@]}"; do for app_data in "${apps_data[@]}"; do
# Keep extended field 7 (size_kb) if present # Keep extended field 7 (size_kb) if present
IFS='|' read -r epoch _ display_name _ size last_used size_kb <<< "$app_data" IFS='|' read -r epoch _ display_name _ size last_used size_kb <<< "$app_data"
menu_options+=("$(format_app_display "$display_name" "$size" "$last_used" "$terminal_width")") menu_options+=("$(format_app_display "$display_name" "$size" "$last_used" "$terminal_width" "$max_name_width")")
# Build csv lists (avoid trailing commas) # Build csv lists (avoid trailing commas)
if [[ $idx -eq 0 ]]; then if [[ $idx -eq 0 ]]; then
epochs_csv="${epoch:-0}" epochs_csv="${epoch:-0}"
@@ -118,7 +162,6 @@ select_apps_for_uninstall() {
fi fi
if [[ $exit_code -ne 0 ]]; then if [[ $exit_code -ne 0 ]]; then
echo "Cancelled"
return 1 return 1
fi fi

View File

@@ -40,7 +40,9 @@ _pm_get_terminal_height() {
# Calculate dynamic items per page based on terminal height # Calculate dynamic items per page based on terminal height
_pm_calculate_items_per_page() { _pm_calculate_items_per_page() {
local term_height=$(_pm_get_terminal_height) local term_height=$(_pm_get_terminal_height)
local reserved=6 # header(2) + footer(3) + spacing(1) # Reserved: header(1) + blank(1) + blank(1) + footer(1-2) = 4-5 rows
# Use 5 to be safe (leaves 1 row buffer when footer wraps to 2 lines)
local reserved=5
local available=$((term_height - reserved)) local available=$((term_height - reserved))
# Ensure minimum and maximum bounds # Ensure minimum and maximum bounds
@@ -153,6 +155,7 @@ paginated_multi_select() {
} }
local -a selected=() local -a selected=()
local selected_count=0 # Cache selection count to avoid O(n) loops on every draw
# Initialize selection array # Initialize selection array
for ((i = 0; i < total_items; i++)); do for ((i = 0; i < total_items; i++)); do
@@ -165,7 +168,11 @@ paginated_multi_select() {
IFS=',' read -ra initial_indices <<< "$cleaned_preselect" IFS=',' read -ra initial_indices <<< "$cleaned_preselect"
for idx in "${initial_indices[@]}"; do for idx in "${initial_indices[@]}"; do
if [[ "$idx" =~ ^[0-9]+$ && $idx -ge 0 && $idx -lt $total_items ]]; then if [[ "$idx" =~ ^[0-9]+$ && $idx -ge 0 && $idx -lt $total_items ]]; then
selected[idx]=true # Only count if not already selected (handles duplicates)
if [[ ${selected[idx]} != true ]]; then
selected[idx]=true
((selected_count++))
fi
fi fi
done done
fi fi
@@ -228,11 +235,13 @@ paginated_multi_select() {
local cols="${COLUMNS:-}" local cols="${COLUMNS:-}"
[[ -z "$cols" ]] && cols=$(tput cols 2> /dev/null || echo 80) [[ -z "$cols" ]] && cols=$(tput cols 2> /dev/null || echo 80)
[[ "$cols" =~ ^[0-9]+$ ]] || cols=80
_strip_ansi_len() { _strip_ansi_len() {
local text="$1" local text="$1"
local stripped local stripped
stripped=$(printf "%s" "$text" | LC_ALL=C awk '{gsub(/\033\[[0-9;]*[A-Za-z]/,""); print}') stripped=$(printf "%s" "$text" | LC_ALL=C awk '{gsub(/\033\[[0-9;]*[A-Za-z]/,""); print}' || true)
[[ -z "$stripped" ]] && stripped="$text"
printf "%d" "${#stripped}" printf "%d" "${#stripped}"
} }
@@ -244,7 +253,10 @@ paginated_multi_select() {
else else
candidate="$line${sep}${s}" candidate="$line${sep}${s}"
fi fi
if (($(_strip_ansi_len "$candidate") > cols)); then local candidate_len
candidate_len=$(_strip_ansi_len "$candidate")
[[ -z "$candidate_len" ]] && candidate_len=0
if ((candidate_len > cols)); then
printf "%s%s\n" "$clear_line" "$line" >&2 printf "%s%s\n" "$clear_line" "$line" >&2
line="$s" line="$s"
else else
@@ -382,11 +394,8 @@ paginated_multi_select() {
printf "\033[H" >&2 printf "\033[H" >&2
local clear_line="\r\033[2K" local clear_line="\r\033[2K"
# Count selections # Use cached selection count (maintained incrementally on toggle)
local selected_count=0 # No need to loop through all items anymore!
for ((i = 0; i < total_items; i++)); do
[[ ${selected[i]} == true ]] && ((selected_count++))
done
# Header only # Header only
printf "${clear_line}${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 printf "${clear_line}${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2
@@ -475,6 +484,22 @@ paginated_multi_select() {
# Footer: single line with controls # Footer: single line with controls
local sep=" ${GRAY}|${NC} " local sep=" ${GRAY}|${NC} "
# Helper to calculate display length without ANSI codes
_calc_len() {
local text="$1"
local stripped
stripped=$(printf "%s" "$text" | LC_ALL=C awk '{gsub(/\033\[[0-9;]*[A-Za-z]/,""); print}')
printf "%d" "${#stripped}"
}
# Common menu items
local nav="${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}${NC}"
local space_select="${GRAY}Space Select${NC}"
local space="${GRAY}Space${NC}"
local enter="${GRAY}Enter${NC}"
local exit="${GRAY}Q Exit${NC}"
if [[ "$filter_mode" == "true" ]]; then if [[ "$filter_mode" == "true" ]]; then
# Filter mode: simple controls without sort # Filter mode: simple controls without sort
local -a _segs_filter=( local -a _segs_filter=(
@@ -485,57 +510,82 @@ paginated_multi_select() {
) )
_print_wrapped_controls "$sep" "${_segs_filter[@]}" _print_wrapped_controls "$sep" "${_segs_filter[@]}"
else else
# Normal mode - single line compact format # Normal mode - prepare dynamic items
local reverse_arrow="↑" local reverse_arrow="↑"
[[ "$sort_reverse" == "true" ]] && reverse_arrow="↓" [[ "$sort_reverse" == "true" ]] && reverse_arrow="↓"
# Determine filter text based on whether filter is active
local filter_text="/ Search" local filter_text="/ Search"
[[ -n "$applied_query" ]] && filter_text="/ Clear" [[ -n "$applied_query" ]] && filter_text="/ Clear"
local refresh="${GRAY}R Refresh${NC}"
local search="${GRAY}${filter_text}${NC}"
local sort_ctrl="${GRAY}S ${sort_status}${NC}"
local order_ctrl="${GRAY}O ${reverse_arrow}${NC}"
if [[ "$has_metadata" == "true" ]]; then if [[ "$has_metadata" == "true" ]]; then
if [[ -n "$applied_query" ]]; then if [[ -n "$applied_query" ]]; then
# Filtering: hide sort controls # Filtering active: hide sort controls
local -a _segs_all=( local -a _segs_all=("$nav" "$space" "$enter" "$refresh" "$search" "$exit")
"${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}${NC}"
"${GRAY}Space${NC}"
"${GRAY}Enter${NC}"
"${GRAY}${filter_text}${NC}"
"${GRAY}Q Exit${NC}"
)
_print_wrapped_controls "$sep" "${_segs_all[@]}" _print_wrapped_controls "$sep" "${_segs_all[@]}"
else else
# Normal: show full controls # Normal: show full controls with dynamic reduction
local -a _segs_all=( local term_width="${COLUMNS:-}"
"${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}${NC}" [[ -z "$term_width" ]] && term_width=$(tput cols 2> /dev/null || echo 80)
"${GRAY}Space Select${NC}" [[ "$term_width" =~ ^[0-9]+$ ]] || term_width=80
"${GRAY}Enter${NC}"
"${GRAY}R Refresh${NC}" # Level 0: Full controls
"${GRAY}${filter_text}${NC}" local -a _segs=("$nav" "$space_select" "$enter" "$refresh" "$search" "$sort_ctrl" "$order_ctrl" "$exit")
"${GRAY}S ${sort_status}${NC}"
"${GRAY}O ${reverse_arrow}${NC}" # Calculate width
"${GRAY}Q Exit${NC}" local total_len=0 seg_count=${#_segs[@]}
) for i in "${!_segs[@]}"; do
_print_wrapped_controls "$sep" "${_segs_all[@]}" total_len=$((total_len + $(_calc_len "${_segs[i]}")))
[[ $i -lt $((seg_count - 1)) ]] && total_len=$((total_len + 3))
done
# Level 1: Remove "Space Select"
if [[ $total_len -gt $term_width ]]; then
_segs=("$nav" "$enter" "$refresh" "$search" "$sort_ctrl" "$order_ctrl" "$exit")
total_len=0
seg_count=${#_segs[@]}
for i in "${!_segs[@]}"; do
total_len=$((total_len + $(_calc_len "${_segs[i]}")))
[[ $i -lt $((seg_count - 1)) ]] && total_len=$((total_len + 3))
done
# Level 2: Remove "S ${sort_status}"
if [[ $total_len -gt $term_width ]]; then
_segs=("$nav" "$enter" "$refresh" "$search" "$order_ctrl" "$exit")
fi
fi
_print_wrapped_controls "$sep" "${_segs[@]}"
fi fi
else else
# Without metadata: basic controls # Without metadata: basic controls
local -a _segs_simple=( local -a _segs_simple=("$nav" "$space_select" "$enter" "$refresh" "$search" "$exit")
"${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}${NC}"
"${GRAY}Space Select${NC}"
"${GRAY}Enter${NC}"
"${GRAY}${filter_text}${NC}"
"${GRAY}Q Exit${NC}"
)
_print_wrapped_controls "$sep" "${_segs_simple[@]}" _print_wrapped_controls "$sep" "${_segs_simple[@]}"
fi fi
fi fi
printf "${clear_line}" >&2 printf "${clear_line}" >&2
} }
# Track previous cursor position for incremental rendering
local prev_cursor_pos=$cursor_pos
local prev_top_index=$top_index
local need_full_redraw=true
# Main interaction loop # Main interaction loop
while true; do while true; do
draw_menu if [[ "$need_full_redraw" == "true" ]]; then
draw_menu
need_full_redraw=false
# Update tracking variables after full redraw
prev_cursor_pos=$cursor_pos
prev_top_index=$top_index
fi
local key local key
key=$(read_key) key=$(read_key)
@@ -549,6 +599,7 @@ paginated_multi_select() {
top_index=0 top_index=0
cursor_pos=0 cursor_pos=0
rebuild_view rebuild_view
need_full_redraw=true
continue continue
fi fi
cleanup cleanup
@@ -558,9 +609,34 @@ paginated_multi_select() {
if [[ ${#view_indices[@]} -eq 0 ]]; then if [[ ${#view_indices[@]} -eq 0 ]]; then
: :
elif [[ $cursor_pos -gt 0 ]]; then elif [[ $cursor_pos -gt 0 ]]; then
# Simple cursor move - only redraw affected rows
local old_cursor=$cursor_pos
((cursor_pos--)) ((cursor_pos--))
local new_cursor=$cursor_pos
# Calculate terminal row positions (+3: row 1=header, row 2=blank, row 3=first item)
local old_row=$((old_cursor + 3))
local new_row=$((new_cursor + 3))
# Quick redraw: update only the two affected rows
printf "\033[%d;1H" "$old_row" >&2
render_item "$old_cursor" false
printf "\033[%d;1H" "$new_row" >&2
render_item "$new_cursor" true
# CRITICAL: Move cursor to footer to avoid visual artifacts
printf "\033[%d;1H" "$((items_per_page + 4))" >&2
prev_cursor_pos=$cursor_pos
# Drain pending input for smoother fast scrolling
drain_pending_input
continue # Skip full redraw
elif [[ $top_index -gt 0 ]]; then elif [[ $top_index -gt 0 ]]; then
((top_index--)) ((top_index--))
prev_cursor_pos=$cursor_pos
prev_top_index=$top_index
need_full_redraw=true # Scrolling requires full redraw
fi fi
;; ;;
"DOWN") "DOWN")
@@ -574,7 +650,29 @@ paginated_multi_select() {
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
# Simple cursor move - only redraw affected rows
local old_cursor=$cursor_pos
((cursor_pos++)) ((cursor_pos++))
local new_cursor=$cursor_pos
# Calculate terminal row positions (+3: row 1=header, row 2=blank, row 3=first item)
local old_row=$((old_cursor + 3))
local new_row=$((new_cursor + 3))
# Quick redraw: update only the two affected rows
printf "\033[%d;1H" "$old_row" >&2
render_item "$old_cursor" false
printf "\033[%d;1H" "$new_row" >&2
render_item "$new_cursor" true
# CRITICAL: Move cursor to footer to avoid visual artifacts
printf "\033[%d;1H" "$((items_per_page + 4))" >&2
prev_cursor_pos=$cursor_pos
# Drain pending input for smoother fast scrolling
drain_pending_input
continue # Skip full redraw
elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then
((top_index++)) ((top_index++))
visible_count=$((${#view_indices[@]} - top_index)) visible_count=$((${#view_indices[@]} - top_index))
@@ -582,6 +680,9 @@ paginated_multi_select() {
if [[ $cursor_pos -ge $visible_count ]]; then if [[ $cursor_pos -ge $visible_count ]]; then
cursor_pos=$((visible_count - 1)) cursor_pos=$((visible_count - 1))
fi fi
prev_cursor_pos=$cursor_pos
prev_top_index=$top_index
need_full_redraw=true # Scrolling requires full redraw
fi fi
fi fi
fi fi
@@ -592,9 +693,25 @@ paginated_multi_select() {
local real="${view_indices[idx]}" local real="${view_indices[idx]}"
if [[ ${selected[real]} == true ]]; then if [[ ${selected[real]} == true ]]; then
selected[real]=false selected[real]=false
((selected_count--))
else else
selected[real]=true selected[real]=true
((selected_count++))
fi fi
# Incremental update: only redraw header (for count) and current row
# Header is at row 1
printf "\033[1;1H\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2
# Redraw current item row (+3: row 1=header, row 2=blank, row 3=first item)
local item_row=$((cursor_pos + 3))
printf "\033[%d;1H" "$item_row" >&2
render_item "$cursor_pos" true
# Move cursor to footer to avoid visual artifacts (items + header + 2 blanks)
printf "\033[%d;1H" "$((items_per_page + 4))" >&2
continue # Skip full redraw
fi fi
;; ;;
"RETRY") "RETRY")
@@ -606,12 +723,14 @@ paginated_multi_select() {
sort_reverse="true" sort_reverse="true"
fi fi
rebuild_view rebuild_view
need_full_redraw=true
fi fi
;; ;;
"CHAR:s" | "CHAR:S") "CHAR:s" | "CHAR:S")
if [[ "$filter_mode" == "true" ]]; then if [[ "$filter_mode" == "true" ]]; then
local ch="${key#CHAR:}" local ch="${key#CHAR:}"
filter_query+="$ch" filter_query+="$ch"
need_full_redraw=true
elif [[ "$has_metadata" == "true" ]]; then elif [[ "$has_metadata" == "true" ]]; then
# Cycle sort mode (only if metadata available) # Cycle sort mode (only if metadata available)
case "$sort_mode" in case "$sort_mode" in
@@ -620,6 +739,7 @@ paginated_multi_select() {
size) sort_mode="date" ;; size) sort_mode="date" ;;
esac esac
rebuild_view rebuild_view
need_full_redraw=true
fi fi
;; ;;
"FILTER") "FILTER")
@@ -631,6 +751,7 @@ paginated_multi_select() {
top_index=0 top_index=0
cursor_pos=0 cursor_pos=0
rebuild_view rebuild_view
need_full_redraw=true
else else
# Enter filter mode # Enter filter mode
filter_mode="true" filter_mode="true"
@@ -639,6 +760,7 @@ paginated_multi_select() {
top_index=0 top_index=0
cursor_pos=0 cursor_pos=0
rebuild_view rebuild_view
need_full_redraw=true
fi fi
;; ;;
"CHAR:j") "CHAR:j")
@@ -701,12 +823,14 @@ paginated_multi_select() {
sort_reverse="true" sort_reverse="true"
fi fi
rebuild_view rebuild_view
need_full_redraw=true
fi fi
;; ;;
"DELETE") "DELETE")
# Backspace filter # Backspace filter
if [[ "$filter_mode" == "true" && -n "$filter_query" ]]; then if [[ "$filter_mode" == "true" && -n "$filter_query" ]]; then
filter_query="${filter_query%?}" filter_query="${filter_query%?}"
need_full_redraw=true
fi fi
;; ;;
CHAR:*) CHAR:*)
@@ -715,6 +839,7 @@ paginated_multi_select() {
# avoid accidental leading spaces # avoid accidental leading spaces
if [[ -n "$filter_query" || "$ch" != " " ]]; then if [[ -n "$filter_query" || "$ch" != " " ]]; then
filter_query+="$ch" filter_query+="$ch"
need_full_redraw=true
fi fi
fi fi
;; ;;
@@ -750,6 +875,7 @@ paginated_multi_select() {
if [[ $idx -lt ${#view_indices[@]} ]]; then if [[ $idx -lt ${#view_indices[@]} ]]; then
local real="${view_indices[idx]}" local real="${view_indices[idx]}"
selected[real]=true selected[real]=true
((selected_count++))
fi fi
fi fi

View File

@@ -2,73 +2,93 @@
set -euo pipefail set -euo pipefail
# Ensure common.sh is loaded # Ensure common.sh is loaded.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
[[ -z "${MOLE_COMMON_LOADED:-}" ]] && source "$SCRIPT_DIR/lib/core/common.sh" [[ -z "${MOLE_COMMON_LOADED:-}" ]] && source "$SCRIPT_DIR/lib/core/common.sh"
# Batch uninstall functionality with minimal confirmations # Batch uninstall with a single confirmation.
# Replaces the overly verbose individual confirmation approach
# Decode and validate base64 encoded file list # User data detection patterns (prompt user to backup if found).
# Returns decoded string if valid, empty string otherwise readonly SENSITIVE_DATA_PATTERNS=(
"\.warp" # Warp terminal configs/themes
"/\.config/" # Standard Unix config directory
"/themes/" # Theme customizations
"/settings/" # Settings directories
"/Application Support/[^/]+/User Data" # Chrome/Electron user data
"/Preferences/[^/]+\.plist" # User preference files
"/Documents/" # User documents
"/\.ssh/" # SSH keys and configs (critical)
"/\.gnupg/" # GPG keys (critical)
)
# Join patterns into a single regex for grep.
SENSITIVE_DATA_REGEX=$(
IFS='|'
echo "${SENSITIVE_DATA_PATTERNS[*]}"
)
# Decode and validate base64 file list (safe for set -e).
decode_file_list() { decode_file_list() {
local encoded="$1" local encoded="$1"
local app_name="$2" local app_name="$2"
local decoded local decoded
# Decode base64 data # macOS uses -D, GNU uses -d. Always return 0 for set -e safety.
if ! decoded=$(printf '%s' "$encoded" | base64 -d 2> /dev/null); then if ! decoded=$(printf '%s' "$encoded" | base64 -D 2> /dev/null); then
log_error "Failed to decode file list for $app_name" if ! decoded=$(printf '%s' "$encoded" | base64 -d 2> /dev/null); then
echo "" log_error "Failed to decode file list for $app_name" >&2
return 1 echo ""
return 0 # Return success with empty string
fi
fi fi
# Validate decoded data doesn't contain null bytes
if [[ "$decoded" =~ $'\0' ]]; then if [[ "$decoded" =~ $'\0' ]]; then
log_warning "File list for $app_name contains null bytes, rejecting" log_warning "File list for $app_name contains null bytes, rejecting" >&2
echo "" echo ""
return 1 return 0 # Return success with empty string
fi fi
# Validate paths look reasonable (each line should be a path or empty)
while IFS= read -r line; do while IFS= read -r line; do
if [[ -n "$line" && ! "$line" =~ ^/ ]]; then if [[ -n "$line" && ! "$line" =~ ^/ ]]; then
log_warning "Invalid path in file list for $app_name: $line" log_warning "Invalid path in file list for $app_name: $line" >&2
echo "" echo ""
return 1 return 0 # Return success with empty string
fi fi
done <<< "$decoded" done <<< "$decoded"
echo "$decoded" echo "$decoded"
return 0 return 0
} }
# Note: find_app_files() and calculate_total_size() functions now in lib/core/common.sh # Note: find_app_files() and calculate_total_size() are in lib/core/common.sh.
# Stop Launch Agents and Daemons for an app # Stop Launch Agents/Daemons for an app.
# Args: $1 = bundle_id, $2 = has_system_files (true/false)
stop_launch_services() { stop_launch_services() {
local bundle_id="$1" local bundle_id="$1"
local has_system_files="${2:-false}" local has_system_files="${2:-false}"
# User-level Launch Agents [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0
for plist in ~/Library/LaunchAgents/"$bundle_id"*.plist; do
[[ -f "$plist" ]] && launchctl unload "$plist" 2> /dev/null || true if [[ -d ~/Library/LaunchAgents ]]; then
done while IFS= read -r -d '' plist; do
launchctl unload "$plist" 2> /dev/null || true
done < <(find ~/Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null)
fi
# System-level services (requires sudo)
if [[ "$has_system_files" == "true" ]]; then if [[ "$has_system_files" == "true" ]]; then
for plist in /Library/LaunchAgents/"$bundle_id"*.plist; do if [[ -d /Library/LaunchAgents ]]; then
[[ -f "$plist" ]] && sudo launchctl unload "$plist" 2> /dev/null || true while IFS= read -r -d '' plist; do
done sudo launchctl unload "$plist" 2> /dev/null || true
for plist in /Library/LaunchDaemons/"$bundle_id"*.plist; do done < <(find /Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null)
[[ -f "$plist" ]] && sudo launchctl unload "$plist" 2> /dev/null || true fi
done if [[ -d /Library/LaunchDaemons ]]; then
while IFS= read -r -d '' plist; do
sudo launchctl unload "$plist" 2> /dev/null || true
done < <(find /Library/LaunchDaemons -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null)
fi
fi fi
} }
# Remove a list of files (handles both regular files and symlinks) # Remove files (handles symlinks, optional sudo).
# Args: $1 = file_list (newline-separated), $2 = use_sudo (true/false)
# Returns: number of files removed
remove_file_list() { remove_file_list() {
local file_list="$1" local file_list="$1"
local use_sudo="${2:-false}" local use_sudo="${2:-false}"
@@ -78,14 +98,12 @@ remove_file_list() {
[[ -n "$file" && -e "$file" ]] || continue [[ -n "$file" && -e "$file" ]] || continue
if [[ -L "$file" ]]; then if [[ -L "$file" ]]; then
# Symlink: use direct rm
if [[ "$use_sudo" == "true" ]]; then if [[ "$use_sudo" == "true" ]]; then
sudo rm "$file" 2> /dev/null && ((count++)) || true sudo rm "$file" 2> /dev/null && ((count++)) || true
else else
rm "$file" 2> /dev/null && ((count++)) || true rm "$file" 2> /dev/null && ((count++)) || true
fi fi
else else
# Regular file/directory: use safe_remove
if [[ "$use_sudo" == "true" ]]; then if [[ "$use_sudo" == "true" ]]; then
safe_sudo_remove "$file" && ((count++)) || true safe_sudo_remove "$file" && ((count++)) || true
else else
@@ -97,8 +115,7 @@ remove_file_list() {
echo "$count" echo "$count"
} }
# Batch uninstall with single confirmation # Batch uninstall with single confirmation.
# Globals: selected_apps (read) - array of selected applications
batch_uninstall_applications() { batch_uninstall_applications() {
local total_size_freed=0 local total_size_freed=0
@@ -108,19 +125,18 @@ batch_uninstall_applications() {
return 0 return 0
fi fi
# Pre-process: Check for running apps and calculate total impact # Pre-scan: running apps, sudo needs, size.
local -a running_apps=() local -a running_apps=()
local -a sudo_apps=() local -a sudo_apps=()
local total_estimated_size=0 local total_estimated_size=0
local -a app_details=() local -a app_details=()
# Analyze selected apps with progress indicator
if [[ -t 1 ]]; then start_inline_spinner "Scanning files..."; fi if [[ -t 1 ]]; then start_inline_spinner "Scanning files..."; fi
for selected_app in "${selected_apps[@]}"; do for selected_app in "${selected_apps[@]}"; do
[[ -z "$selected_app" ]] && continue [[ -z "$selected_app" ]] && continue
IFS='|' read -r _ app_path app_name bundle_id _ _ <<< "$selected_app" IFS='|' read -r _ app_path app_name bundle_id _ _ <<< "$selected_app"
# Check if app is running using executable name from bundle # Check running app by bundle executable if available.
local exec_name="" local exec_name=""
if [[ -e "$app_path/Contents/Info.plist" ]]; then if [[ -e "$app_path/Contents/Info.plist" ]]; then
exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "") exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "")
@@ -130,17 +146,21 @@ batch_uninstall_applications() {
running_apps+=("$app_name") running_apps+=("$app_name")
fi fi
# Check if app requires sudo to delete (either app bundle or system files) # Sudo needed if bundle owner/dir is not writable or system files exist.
local needs_sudo=false local needs_sudo=false
if [[ ! -w "$(dirname "$app_path")" ]] || [[ "$(get_file_owner "$app_path")" == "root" ]]; then local app_owner=$(get_file_owner "$app_path")
local current_user=$(whoami)
if [[ ! -w "$(dirname "$app_path")" ]] ||
[[ "$app_owner" == "root" ]] ||
[[ -n "$app_owner" && "$app_owner" != "$current_user" ]]; then
needs_sudo=true needs_sudo=true
fi fi
# Calculate size for summary (including system files) # Size estimate includes related and system files.
local app_size_kb=$(get_path_size_kb "$app_path") local app_size_kb=$(get_path_size_kb "$app_path")
local related_files=$(find_app_files "$bundle_id" "$app_name") local related_files=$(find_app_files "$bundle_id" "$app_name")
local related_size_kb=$(calculate_total_size "$related_files") local related_size_kb=$(calculate_total_size "$related_files")
# system_files is a newline-separated string, not an array # system_files is a newline-separated string, not an array.
# shellcheck disable=SC2178,SC2128 # shellcheck disable=SC2178,SC2128
local system_files=$(find_app_system_files "$bundle_id" "$app_name") local system_files=$(find_app_system_files "$bundle_id" "$app_name")
# shellcheck disable=SC2128 # shellcheck disable=SC2128
@@ -148,7 +168,6 @@ batch_uninstall_applications() {
local total_kb=$((app_size_kb + related_size_kb + system_size_kb)) local total_kb=$((app_size_kb + related_size_kb + system_size_kb))
((total_estimated_size += total_kb)) ((total_estimated_size += total_kb))
# Check if system files require sudo
# shellcheck disable=SC2128 # shellcheck disable=SC2128
if [[ -n "$system_files" ]]; then if [[ -n "$system_files" ]]; then
needs_sudo=true needs_sudo=true
@@ -158,25 +177,44 @@ batch_uninstall_applications() {
sudo_apps+=("$app_name") sudo_apps+=("$app_name")
fi fi
# Store details for later use # Check for sensitive user data once.
# Base64 encode file lists to handle multi-line data safely (single line) local has_sensitive_data="false"
if [[ -n "$related_files" ]] && echo "$related_files" | grep -qE "$SENSITIVE_DATA_REGEX"; then
has_sensitive_data="true"
fi
# Store details for later use (base64 keeps lists on one line).
local encoded_files local encoded_files
encoded_files=$(printf '%s' "$related_files" | base64 | tr -d '\n') encoded_files=$(printf '%s' "$related_files" | base64 | tr -d '\n')
local encoded_system_files local encoded_system_files
encoded_system_files=$(printf '%s' "$system_files" | base64 | tr -d '\n') encoded_system_files=$(printf '%s' "$system_files" | base64 | tr -d '\n')
app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$encoded_files|$encoded_system_files") app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$encoded_files|$encoded_system_files|$has_sensitive_data|$needs_sudo")
done done
if [[ -t 1 ]]; then stop_inline_spinner; fi if [[ -t 1 ]]; then stop_inline_spinner; fi
# Format size display (convert KB to bytes for bytes_to_human())
local size_display=$(bytes_to_human "$((total_estimated_size * 1024))") local size_display=$(bytes_to_human "$((total_estimated_size * 1024))")
# Display detailed file list for each app before confirmation
echo "" echo ""
echo -e "${PURPLE_BOLD}Files to be removed:${NC}" echo -e "${PURPLE_BOLD}Files to be removed:${NC}"
echo "" echo ""
# Warn if user data is detected.
local has_user_data=false
for detail in "${app_details[@]}"; do for detail in "${app_details[@]}"; do
IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files <<< "$detail" IFS='|' read -r _ _ _ _ _ _ has_sensitive_data <<< "$detail"
if [[ "$has_sensitive_data" == "true" ]]; then
has_user_data=true
break
fi
done
if [[ "$has_user_data" == "true" ]]; then
echo -e "${YELLOW}${ICON_WARNING}${NC} ${YELLOW}Note: Some apps contain user configurations/themes${NC}"
echo ""
fi
for detail in "${app_details[@]}"; do
IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag <<< "$detail"
local related_files=$(decode_file_list "$encoded_files" "$app_name") local related_files=$(decode_file_list "$encoded_files" "$app_name")
local system_files=$(decode_file_list "$encoded_system_files" "$app_name") local system_files=$(decode_file_list "$encoded_system_files" "$app_name")
local app_size_display=$(bytes_to_human "$((total_kb * 1024))") local app_size_display=$(bytes_to_human "$((total_kb * 1024))")
@@ -184,7 +222,7 @@ batch_uninstall_applications() {
echo -e "${BLUE}${ICON_CONFIRM}${NC} ${app_name} ${GRAY}(${app_size_display})${NC}" echo -e "${BLUE}${ICON_CONFIRM}${NC} ${app_name} ${GRAY}(${app_size_display})${NC}"
echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${app_path/$HOME/~}" echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${app_path/$HOME/~}"
# Show related files (limit to 5 most important ones for brevity) # Show related files (limit to 5).
local file_count=0 local file_count=0
local max_files=5 local max_files=5
while IFS= read -r file; do while IFS= read -r file; do
@@ -196,7 +234,7 @@ batch_uninstall_applications() {
fi fi
done <<< "$related_files" done <<< "$related_files"
# Show system files # Show system files (limit to 5).
local sys_file_count=0 local sys_file_count=0
while IFS= read -r file; do while IFS= read -r file; do
if [[ -n "$file" && -e "$file" ]]; then if [[ -n "$file" && -e "$file" ]]; then
@@ -207,7 +245,6 @@ batch_uninstall_applications() {
fi fi
done <<< "$system_files" done <<< "$system_files"
# Show count of remaining files if truncated
local total_hidden=$((file_count > max_files ? file_count - max_files : 0)) local total_hidden=$((file_count > max_files ? file_count - max_files : 0))
((total_hidden += sys_file_count > max_files ? sys_file_count - max_files : 0)) ((total_hidden += sys_file_count > max_files ? sys_file_count - max_files : 0))
if [[ $total_hidden -gt 0 ]]; then if [[ $total_hidden -gt 0 ]]; then
@@ -215,7 +252,7 @@ batch_uninstall_applications() {
fi fi
done done
# Show summary and get batch confirmation first (before asking for password) # Confirmation before requesting sudo.
local app_total=${#selected_apps[@]} local app_total=${#selected_apps[@]}
local app_text="app" local app_text="app"
[[ $app_total -gt 1 ]] && app_text="apps" [[ $app_total -gt 1 ]] && app_text="apps"
@@ -247,9 +284,8 @@ batch_uninstall_applications() {
;; ;;
esac esac
# User confirmed, now request sudo access if needed # Request sudo if needed.
if [[ ${#sudo_apps[@]} -gt 0 ]]; then if [[ ${#sudo_apps[@]} -gt 0 ]]; then
# Check if sudo is already cached
if ! sudo -n true 2> /dev/null; then if ! sudo -n true 2> /dev/null; then
if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then
echo "" echo ""
@@ -257,10 +293,9 @@ batch_uninstall_applications() {
return 1 return 1
fi fi
fi fi
# Start sudo keepalive with robust parent checking # Keep sudo alive during uninstall.
parent_pid=$$ parent_pid=$$
(while true; do (while true; do
# Check if parent process still exists first
if ! kill -0 "$parent_pid" 2> /dev/null; then if ! kill -0 "$parent_pid" 2> /dev/null; then
exit 0 exit 0
fi fi
@@ -272,48 +307,60 @@ batch_uninstall_applications() {
if [[ -t 1 ]]; then start_inline_spinner "Uninstalling apps..."; fi if [[ -t 1 ]]; then start_inline_spinner "Uninstalling apps..."; fi
# Force quit running apps first (batch) # Perform uninstallations (silent mode, show results at end).
# Note: Apps are already killed in the individual uninstall loop below with app_path for precise matching
# Perform uninstallations (silent mode, show results at end)
if [[ -t 1 ]]; then stop_inline_spinner; fi if [[ -t 1 ]]; then stop_inline_spinner; fi
local success_count=0 failed_count=0 local success_count=0 failed_count=0
local -a failed_items=() local -a failed_items=()
local -a success_items=() local -a success_items=()
for detail in "${app_details[@]}"; do for detail in "${app_details[@]}"; do
IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files <<< "$detail" IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo <<< "$detail"
local related_files=$(decode_file_list "$encoded_files" "$app_name") local related_files=$(decode_file_list "$encoded_files" "$app_name")
local system_files=$(decode_file_list "$encoded_system_files" "$app_name") local system_files=$(decode_file_list "$encoded_system_files" "$app_name")
local reason="" local reason=""
local needs_sudo=false
[[ ! -w "$(dirname "$app_path")" || "$(get_file_owner "$app_path")" == "root" ]] && needs_sudo=true
# Stop Launch Agents and Daemons before removal # Stop Launch Agents/Daemons before removal.
local has_system_files="false" local has_system_files="false"
[[ -n "$system_files" ]] && has_system_files="true" [[ -n "$system_files" ]] && has_system_files="true"
stop_launch_services "$bundle_id" "$has_system_files" stop_launch_services "$bundle_id" "$has_system_files"
# Force quit app if still running
if ! force_kill_app "$app_name" "$app_path"; then if ! force_kill_app "$app_name" "$app_path"; then
reason="still running" reason="still running"
fi fi
# Remove the application only if not running # Remove the application only if not running.
if [[ -z "$reason" ]]; then if [[ -z "$reason" ]]; then
if [[ "$needs_sudo" == true ]]; then if [[ "$needs_sudo" == true ]]; then
safe_sudo_remove "$app_path" || reason="remove failed" if ! safe_sudo_remove "$app_path"; then
local app_owner=$(get_file_owner "$app_path")
local current_user=$(whoami)
if [[ -n "$app_owner" && "$app_owner" != "$current_user" && "$app_owner" != "root" ]]; then
reason="owned by $app_owner"
else
reason="permission denied"
fi
fi
else else
safe_remove "$app_path" true || reason="remove failed" safe_remove "$app_path" true || reason="remove failed"
fi fi
fi fi
# Remove related files if app removal succeeded # Remove related files if app removal succeeded.
if [[ -z "$reason" ]]; then if [[ -z "$reason" ]]; then
# Remove user-level files
remove_file_list "$related_files" "false" > /dev/null remove_file_list "$related_files" "false" > /dev/null
# Remove system-level files (requires sudo)
remove_file_list "$system_files" "true" > /dev/null remove_file_list "$system_files" "true" > /dev/null
# Clean up macOS defaults (preference domains).
if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then
if defaults read "$bundle_id" &> /dev/null; then
defaults delete "$bundle_id" 2> /dev/null || true
fi
# ByHost preferences (machine-specific).
if [[ -d ~/Library/Preferences/ByHost ]]; then
find ~/Library/Preferences/ByHost -maxdepth 1 -name "${bundle_id}.*.plist" -delete 2> /dev/null || true
fi
fi
((total_size_freed += total_kb)) ((total_size_freed += total_kb))
((success_count++)) ((success_count++))
((files_cleaned++)) ((files_cleaned++))
@@ -341,7 +388,7 @@ batch_uninstall_applications() {
success_line+=", freed ${GREEN}${freed_display}${NC}" success_line+=", freed ${GREEN}${freed_display}${NC}"
fi fi
# Format app list with max 3 per line # Format app list with max 3 per line.
if [[ -n "$success_list" ]]; then if [[ -n "$success_list" ]]; then
local idx=0 local idx=0
local is_first_line=true local is_first_line=true
@@ -351,25 +398,20 @@ batch_uninstall_applications() {
local display_item="${GREEN}${app_name}${NC}" local display_item="${GREEN}${app_name}${NC}"
if ((idx % 3 == 0)); then if ((idx % 3 == 0)); then
# Start new line
if [[ -n "$current_line" ]]; then if [[ -n "$current_line" ]]; then
summary_details+=("$current_line") summary_details+=("$current_line")
fi fi
if [[ "$is_first_line" == true ]]; then if [[ "$is_first_line" == true ]]; then
# First line: append to success_line
current_line="${success_line}: $display_item" current_line="${success_line}: $display_item"
is_first_line=false is_first_line=false
else else
# Subsequent lines: just the apps
current_line="$display_item" current_line="$display_item"
fi fi
else else
# Add to current line
current_line="$current_line, $display_item" current_line="$current_line, $display_item"
fi fi
((idx++)) ((idx++))
done done
# Add the last line
if [[ -n "$current_line" ]]; then if [[ -n "$current_line" ]]; then
summary_details+=("$current_line") summary_details+=("$current_line")
fi fi
@@ -394,7 +436,8 @@ batch_uninstall_applications() {
case "$first_reason" in case "$first_reason" in
still*running*) reason_summary="is still running" ;; still*running*) reason_summary="is still running" ;;
remove*failed*) reason_summary="could not be removed" ;; remove*failed*) reason_summary="could not be removed" ;;
permission*) reason_summary="permission denied" ;; permission*denied*) reason_summary="permission denied" ;;
owned*by*) reason_summary="$first_reason (try with sudo)" ;;
*) reason_summary="$first_reason" ;; *) reason_summary="$first_reason" ;;
esac esac
fi fi
@@ -414,12 +457,11 @@ batch_uninstall_applications() {
print_summary_block "$title" "${summary_details[@]}" print_summary_block "$title" "${summary_details[@]}"
printf '\n' printf '\n'
# Clean up Dock entries for uninstalled apps # Clean up Dock entries for uninstalled apps.
if [[ $success_count -gt 0 ]]; then if [[ $success_count -gt 0 ]]; then
local -a removed_paths=() local -a removed_paths=()
for detail in "${app_details[@]}"; do for detail in "${app_details[@]}"; do
IFS='|' read -r app_name app_path _ _ _ _ <<< "$detail" IFS='|' read -r app_name app_path _ _ _ _ <<< "$detail"
# Check if this app was successfully removed
for success_name in "${success_items[@]}"; do for success_name in "${success_items[@]}"; do
if [[ "$success_name" == "$app_name" ]]; then if [[ "$success_name" == "$app_name" ]]; then
removed_paths+=("$app_path") removed_paths+=("$app_path")
@@ -432,14 +474,14 @@ batch_uninstall_applications() {
fi fi
fi fi
# Clean up sudo keepalive if it was started # Clean up sudo keepalive if it was started.
if [[ -n "${sudo_keepalive_pid:-}" ]]; then if [[ -n "${sudo_keepalive_pid:-}" ]]; then
kill "$sudo_keepalive_pid" 2> /dev/null || true kill "$sudo_keepalive_pid" 2> /dev/null || true
wait "$sudo_keepalive_pid" 2> /dev/null || true wait "$sudo_keepalive_pid" 2> /dev/null || true
sudo_keepalive_pid="" sudo_keepalive_pid=""
fi fi
# Invalidate cache if any apps were successfully uninstalled # Invalidate cache if any apps were successfully uninstalled.
if [[ $success_count -gt 0 ]]; then if [[ $success_count -gt 0 ]]; then
local cache_file="$HOME/.cache/mole/app_scan_cache" local cache_file="$HOME/.cache/mole/app_scan_cache"
rm -f "$cache_file" 2> /dev/null || true rm -f "$cache_file" 2> /dev/null || true

225
mole
View File

@@ -1,76 +1,91 @@
#!/bin/bash #!/bin/bash
# Mole - Main Entry Point # Mole - Main CLI entrypoint.
# A comprehensive macOS maintenance tool # Routes subcommands and interactive menu.
# # Handles update/remove flows.
# Clean - Remove junk files and optimize system
# Uninstall - Remove applications completely
# Analyze - Interactive disk space explorer
#
# Usage:
# ./mole # Interactive main menu
# ./mole clean # Direct clean mode
# ./mole uninstall # Direct uninstall mode
# ./mole analyze # Disk space explorer
# ./mole --help # Show help
set -euo pipefail set -euo pipefail
# Get script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source common functions
source "$SCRIPT_DIR/lib/core/common.sh" source "$SCRIPT_DIR/lib/core/common.sh"
source "$SCRIPT_DIR/lib/core/commands.sh"
# Version info trap cleanup_temp_files EXIT INT TERM
VERSION="1.13.5"
# Version and update helpers
VERSION="1.17.0"
MOLE_TAGLINE="Deep clean and optimize your Mac." MOLE_TAGLINE="Deep clean and optimize your Mac."
# Check if Touch ID is already configured
is_touchid_configured() { is_touchid_configured() {
local pam_sudo_file="/etc/pam.d/sudo" local pam_sudo_file="/etc/pam.d/sudo"
[[ -f "$pam_sudo_file" ]] && grep -q "pam_tid.so" "$pam_sudo_file" 2> /dev/null [[ -f "$pam_sudo_file" ]] && grep -q "pam_tid.so" "$pam_sudo_file" 2> /dev/null
} }
# Get latest version from remote repository
get_latest_version() { get_latest_version() {
curl -fsSL --connect-timeout 2 --max-time 3 -H "Cache-Control: no-cache" \ curl -fsSL --connect-timeout 2 --max-time 3 -H "Cache-Control: no-cache" \
"https://raw.githubusercontent.com/tw93/mole/main/mole" 2> /dev/null | "https://raw.githubusercontent.com/tw93/mole/main/mole" 2> /dev/null |
grep '^VERSION=' | head -1 | sed 's/VERSION="\(.*\)"/\1/' grep '^VERSION=' | head -1 | sed 's/VERSION="\(.*\)"/\1/'
} }
# Get latest version from GitHub API
# This works for both Homebrew and manual installs since versions are synced
get_latest_version_from_github() { get_latest_version_from_github() {
local version local version
version=$(curl -fsSL --connect-timeout 2 --max-time 3 \ version=$(curl -fsSL --connect-timeout 2 --max-time 3 \
"https://api.github.com/repos/tw93/mole/releases/latest" 2> /dev/null | "https://api.github.com/repos/tw93/mole/releases/latest" 2> /dev/null |
grep '"tag_name"' | head -1 | sed -E 's/.*"([^"]+)".*/\1/') grep '"tag_name"' | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
# Remove 'v' or 'V' prefix if present
version="${version#v}" version="${version#v}"
version="${version#V}" version="${version#V}"
echo "$version" echo "$version"
} }
# Check if installed via Homebrew # Install detection (Homebrew vs manual).
is_homebrew_install() { is_homebrew_install() {
command -v brew > /dev/null 2>&1 && brew list mole > /dev/null 2>&1 local mole_path
mole_path=$(command -v mole 2> /dev/null) || return 1
if [[ -L "$mole_path" ]] && readlink "$mole_path" | grep -q "Cellar/mole"; then
if command -v brew > /dev/null 2>&1; then
brew list --formula 2> /dev/null | grep -q "^mole$" && return 0
else
return 1
fi
fi
if [[ -f "$mole_path" ]]; then
case "$mole_path" in
/opt/homebrew/bin/mole | /usr/local/bin/mole)
if [[ -d /opt/homebrew/Cellar/mole ]] || [[ -d /usr/local/Cellar/mole ]]; then
if command -v brew > /dev/null 2>&1; then
brew list --formula 2> /dev/null | grep -q "^mole$" && return 0
else
return 0 # Cellar exists, probably Homebrew install
fi
fi
;;
esac
fi
if command -v brew > /dev/null 2>&1; then
local brew_prefix
brew_prefix=$(brew --prefix 2> /dev/null)
if [[ -n "$brew_prefix" && "$mole_path" == "$brew_prefix/bin/mole" && -d "$brew_prefix/Cellar/mole" ]]; then
brew list --formula 2> /dev/null | grep -q "^mole$" && return 0
fi
fi
return 1
} }
# Check for updates (non-blocking, always check in background) # Background update notice
check_for_updates() { check_for_updates() {
local msg_cache="$HOME/.cache/mole/update_message" local msg_cache="$HOME/.cache/mole/update_message"
mkdir -p "$(dirname "$msg_cache")" 2> /dev/null ensure_user_dir "$(dirname "$msg_cache")"
ensure_user_file "$msg_cache"
# Background version check (save to file, don't output)
# Always check in background, display result from previous check
( (
local latest local latest
# Use GitHub API for version check (works for both Homebrew and manual installs)
# Try API first (faster and more reliable)
latest=$(get_latest_version_from_github) latest=$(get_latest_version_from_github)
if [[ -z "$latest" ]]; then if [[ -z "$latest" ]]; then
# Fallback to parsing mole script from raw GitHub
latest=$(get_latest_version) latest=$(get_latest_version)
fi fi
@@ -83,7 +98,6 @@ check_for_updates() {
disown 2> /dev/null || true disown 2> /dev/null || true
} }
# Show update notification if available
show_update_notification() { show_update_notification() {
local msg_cache="$HOME/.cache/mole/update_message" local msg_cache="$HOME/.cache/mole/update_message"
if [[ -f "$msg_cache" && -s "$msg_cache" ]]; then if [[ -f "$msg_cache" && -s "$msg_cache" ]]; then
@@ -92,6 +106,7 @@ show_update_notification() {
fi fi
} }
# UI helpers
show_brand_banner() { show_brand_banner() {
cat << EOF cat << EOF
${GREEN} __ __ _ ${NC} ${GREEN} __ __ _ ${NC}
@@ -104,7 +119,6 @@ EOF
} }
animate_mole_intro() { animate_mole_intro() {
# Skip animation if stdout isn't a TTY (non-interactive)
if [[ ! -t 1 ]]; then if [[ ! -t 1 ]]; then
return return
fi fi
@@ -197,8 +211,7 @@ show_version() {
local sip_status local sip_status
if command -v csrutil > /dev/null; then if command -v csrutil > /dev/null; then
sip_status=$(csrutil status 2> /dev/null | grep -o "enabled\|disabled" || echo "Unknown") sip_status=$(csrutil status 2> /dev/null | grep -o "enabled\|disabled" || echo "Unknown")
# Capitalize first letter sip_status="$(tr '[:lower:]' '[:upper:]' <<< "${sip_status:0:1}")${sip_status:1}"
sip_status="$(tr '[:lower:]' '[:upper:]' <<< ${sip_status:0:1})${sip_status:1}"
else else
sip_status="Unknown" sip_status="Unknown"
fi fi
@@ -226,43 +239,39 @@ show_help() {
echo echo
printf "%s%s%s\n" "$BLUE" "COMMANDS" "$NC" printf "%s%s%s\n" "$BLUE" "COMMANDS" "$NC"
printf " %s%-28s%s %s\n" "$GREEN" "mo" "$NC" "Main menu" printf " %s%-28s%s %s\n" "$GREEN" "mo" "$NC" "Main menu"
printf " %s%-28s%s %s\n" "$GREEN" "mo clean" "$NC" "Free up disk space" for entry in "${MOLE_COMMANDS[@]}"; do
printf " %s%-28s%s %s\n" "$GREEN" "mo uninstall" "$NC" "Remove apps completely" local name="${entry%%:*}"
printf " %s%-28s%s %s\n" "$GREEN" "mo optimize" "$NC" "Check and maintain system" local desc="${entry#*:}"
printf " %s%-28s%s %s\n" "$GREEN" "mo analyze" "$NC" "Explore disk usage" local display="mo $name"
printf " %s%-28s%s %s\n" "$GREEN" "mo status" "$NC" "Monitor system health" [[ "$name" == "help" ]] && display="mo --help"
printf " %s%-28s%s %s\n" "$GREEN" "mo touchid" "$NC" "Configure Touch ID for sudo" [[ "$name" == "version" ]] && display="mo --version"
printf " %s%-28s%s %s\n" "$GREEN" "mo update" "$NC" "Update to latest version" printf " %s%-28s%s %s\n" "$GREEN" "$display" "$NC" "$desc"
printf " %s%-28s%s %s\n" "$GREEN" "mo remove" "$NC" "Remove Mole from system" done
printf " %s%-28s%s %s\n" "$GREEN" "mo --help" "$NC" "Show help"
printf " %s%-28s%s %s\n" "$GREEN" "mo --version" "$NC" "Show version"
echo echo
printf " %s%-28s%s %s\n" "$GREEN" "mo clean --dry-run" "$NC" "Preview cleanup" printf " %s%-28s%s %s\n" "$GREEN" "mo clean --dry-run" "$NC" "Preview cleanup"
printf " %s%-28s%s %s\n" "$GREEN" "mo clean --whitelist" "$NC" "Manage protected caches" printf " %s%-28s%s %s\n" "$GREEN" "mo clean --whitelist" "$NC" "Manage protected caches"
printf " %s%-28s%s %s\n" "$GREEN" "mo uninstall --force-rescan" "$NC" "Rescan apps and refresh cache"
printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --dry-run" "$NC" "Preview optimization"
printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items"
printf " %s%-28s%s %s\n" "$GREEN" "mo purge --paths" "$NC" "Configure scan directories"
echo echo
printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC" printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC"
printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs" printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs"
echo echo
} }
# Simple update function # Update flow (Homebrew or installer).
update_mole() { update_mole() {
# Set up cleanup trap for update process
local update_interrupted=false local update_interrupted=false
trap 'update_interrupted=true; echo ""; log_error "Update interrupted by user"; exit 130' INT TERM trap 'update_interrupted=true; echo ""; exit 130' INT TERM
# Check if installed via Homebrew
if is_homebrew_install; then if is_homebrew_install; then
update_via_homebrew "$VERSION" update_via_homebrew "$VERSION"
exit 0 exit 0
fi fi
# Check for updates
local latest local latest
latest=$(get_latest_version_from_github) latest=$(get_latest_version_from_github)
# Fallback to raw GitHub if API fails
[[ -z "$latest" ]] && latest=$(get_latest_version) [[ -z "$latest" ]] && latest=$(get_latest_version)
if [[ -z "$latest" ]]; then if [[ -z "$latest" ]]; then
@@ -279,7 +288,6 @@ update_mole() {
exit 0 exit 0
fi fi
# Download and run installer with progress
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
start_inline_spinner "Downloading latest version..." start_inline_spinner "Downloading latest version..."
else else
@@ -293,7 +301,6 @@ update_mole() {
exit 1 exit 1
} }
# Download installer with progress and better error handling
local download_error="" local download_error=""
if command -v curl > /dev/null 2>&1; then if command -v curl > /dev/null 2>&1; then
download_error=$(curl -fsSL --connect-timeout 10 --max-time 60 "$installer_url" -o "$tmp_installer" 2>&1) || { download_error=$(curl -fsSL --connect-timeout 10 --max-time 60 "$installer_url" -o "$tmp_installer" 2>&1) || {
@@ -302,7 +309,6 @@ update_mole() {
rm -f "$tmp_installer" rm -f "$tmp_installer"
log_error "Update failed (curl error: $curl_exit)" log_error "Update failed (curl error: $curl_exit)"
# Provide helpful error messages based on curl exit codes
case $curl_exit in case $curl_exit in
6) echo -e "${YELLOW}Tip:${NC} Could not resolve host. Check DNS or network connection." ;; 6) echo -e "${YELLOW}Tip:${NC} Could not resolve host. Check DNS or network connection." ;;
7) echo -e "${YELLOW}Tip:${NC} Failed to connect. Check network or proxy settings." ;; 7) echo -e "${YELLOW}Tip:${NC} Failed to connect. Check network or proxy settings." ;;
@@ -333,7 +339,6 @@ update_mole() {
if [[ -t 1 ]]; then stop_inline_spinner; fi if [[ -t 1 ]]; then stop_inline_spinner; fi
chmod +x "$tmp_installer" chmod +x "$tmp_installer"
# Determine install directory
local mole_path local mole_path
mole_path="$(command -v mole 2> /dev/null || echo "$0")" mole_path="$(command -v mole 2> /dev/null || echo "$0")"
local install_dir local install_dir
@@ -360,7 +365,6 @@ update_mole() {
echo "Installing update..." echo "Installing update..."
fi fi
# Helper function to process installer output
process_install_output() { process_install_output() {
local output="$1" local output="$1"
if [[ -t 1 ]]; then stop_inline_spinner; fi if [[ -t 1 ]]; then stop_inline_spinner; fi
@@ -371,7 +375,6 @@ update_mole() {
printf '\n%s\n' "$filtered_output" printf '\n%s\n' "$filtered_output"
fi fi
# Only show success message if installer didn't already do so
if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then if ! printf '%s\n' "$output" | grep -Eq "Updated to latest version|Already on latest version"; then
local new_version local new_version
new_version=$("$mole_path" --version 2> /dev/null | awk 'NF {print $NF}' || echo "") new_version=$("$mole_path" --version 2> /dev/null | awk 'NF {print $NF}' || echo "")
@@ -381,13 +384,16 @@ update_mole() {
fi fi
} }
# Run installer with visible output (but capture for error handling)
local install_output local install_output
if install_output=$("$tmp_installer" --prefix "$install_dir" --config "$HOME/.config/mole" --update 2>&1); then local update_tag="V${latest#V}"
local config_dir="${MOLE_CONFIG_DIR:-$SCRIPT_DIR}"
if [[ ! -f "$config_dir/lib/core/common.sh" ]]; then
config_dir="$HOME/.config/mole"
fi
if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" --update 2>&1); then
process_install_output "$install_output" process_install_output "$install_output"
else else
# Retry without --update flag if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" 2>&1); then
if install_output=$("$tmp_installer" --prefix "$install_dir" --config "$HOME/.config/mole" 2>&1); then
process_install_output "$install_output" process_install_output "$install_output"
else else
if [[ -t 1 ]]; then stop_inline_spinner; fi if [[ -t 1 ]]; then stop_inline_spinner; fi
@@ -402,9 +408,8 @@ update_mole() {
rm -f "$HOME/.cache/mole/update_message" rm -f "$HOME/.cache/mole/update_message"
} }
# Remove Mole from system # Remove flow (Homebrew + manual + config/cache).
remove_mole() { remove_mole() {
# Detect all installations with loading
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
start_inline_spinner "Detecting Mole installations..." start_inline_spinner "Detecting Mole installations..."
else else
@@ -412,25 +417,37 @@ remove_mole() {
fi fi
local is_homebrew=false local is_homebrew=false
local brew_cmd=""
local brew_has_mole="false"
local -a manual_installs=() local -a manual_installs=()
local -a alias_installs=() local -a alias_installs=()
# Check Homebrew if command -v brew > /dev/null 2>&1; then
if is_homebrew_install; then brew_cmd="brew"
elif [[ -x "/opt/homebrew/bin/brew" ]]; then
brew_cmd="/opt/homebrew/bin/brew"
elif [[ -x "/usr/local/bin/brew" ]]; then
brew_cmd="/usr/local/bin/brew"
fi
if [[ -n "$brew_cmd" ]]; then
if "$brew_cmd" list --formula 2> /dev/null | grep -q "^mole$"; then
brew_has_mole="true"
fi
fi
if [[ "$brew_has_mole" == "true" ]] || is_homebrew_install; then
is_homebrew=true is_homebrew=true
fi fi
# Find mole installations using which/command
local found_mole local found_mole
found_mole=$(command -v mole 2> /dev/null || true) found_mole=$(command -v mole 2> /dev/null || true)
if [[ -n "$found_mole" && -f "$found_mole" ]]; then if [[ -n "$found_mole" && -f "$found_mole" ]]; then
# Check if it's not a Homebrew symlink
if [[ ! -L "$found_mole" ]] || ! readlink "$found_mole" | grep -q "Cellar/mole"; then if [[ ! -L "$found_mole" ]] || ! readlink "$found_mole" | grep -q "Cellar/mole"; then
manual_installs+=("$found_mole") manual_installs+=("$found_mole")
fi fi
fi fi
# Also check common locations as fallback
local -a fallback_paths=( local -a fallback_paths=(
"/usr/local/bin/mole" "/usr/local/bin/mole"
"$HOME/.local/bin/mole" "$HOME/.local/bin/mole"
@@ -439,21 +456,18 @@ remove_mole() {
for path in "${fallback_paths[@]}"; do for path in "${fallback_paths[@]}"; do
if [[ -f "$path" && "$path" != "$found_mole" ]]; then if [[ -f "$path" && "$path" != "$found_mole" ]]; then
# Check if it's not a Homebrew symlink
if [[ ! -L "$path" ]] || ! readlink "$path" | grep -q "Cellar/mole"; then if [[ ! -L "$path" ]] || ! readlink "$path" | grep -q "Cellar/mole"; then
manual_installs+=("$path") manual_installs+=("$path")
fi fi
fi fi
done done
# Find mo alias
local found_mo local found_mo
found_mo=$(command -v mo 2> /dev/null || true) found_mo=$(command -v mo 2> /dev/null || true)
if [[ -n "$found_mo" && -f "$found_mo" ]]; then if [[ -n "$found_mo" && -f "$found_mo" ]]; then
alias_installs+=("$found_mo") alias_installs+=("$found_mo")
fi fi
# Also check common locations for mo
local -a alias_fallback=( local -a alias_fallback=(
"/usr/local/bin/mo" "/usr/local/bin/mo"
"$HOME/.local/bin/mo" "$HOME/.local/bin/mo"
@@ -472,7 +486,6 @@ remove_mole() {
printf '\n' printf '\n'
# Check if anything to remove
local manual_count=${#manual_installs[@]} local manual_count=${#manual_installs[@]}
local alias_count=${#alias_installs[@]} local alias_count=${#alias_installs[@]}
if [[ "$is_homebrew" == "false" && ${manual_count:-0} -eq 0 && ${alias_count:-0} -eq 0 ]]; then if [[ "$is_homebrew" == "false" && ${manual_count:-0} -eq 0 && ${alias_count:-0} -eq 0 ]]; then
@@ -480,7 +493,6 @@ remove_mole() {
exit 0 exit 0
fi fi
# Show what will be removed
echo -e "${YELLOW}Remove Mole${NC} - will delete the following:" echo -e "${YELLOW}Remove Mole${NC} - will delete the following:"
if [[ "$is_homebrew" == "true" ]]; then if [[ "$is_homebrew" == "true" ]]; then
echo " - Mole via Homebrew" echo " - Mole via Homebrew"
@@ -492,45 +504,48 @@ remove_mole() {
echo " - ~/.cache/mole" echo " - ~/.cache/mole"
echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to confirm, ${GRAY}ESC${NC} to cancel: " echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to confirm, ${GRAY}ESC${NC} to cancel: "
# Read single key
IFS= read -r -s -n1 key || key="" IFS= read -r -s -n1 key || key=""
drain_pending_input # Clean up any escape sequence remnants drain_pending_input # Clean up any escape sequence remnants
case "$key" in case "$key" in
$'\e') $'\e')
echo -e "${GRAY}Cancelled${NC}"
echo ""
exit 0 exit 0
;; ;;
"" | $'\n' | $'\r') "" | $'\n' | $'\r')
printf "\r\033[K" # Clear the prompt line printf "\r\033[K" # Clear the prompt line
# Continue with removal
;; ;;
*) *)
echo -e "${GRAY}Cancelled${NC}"
echo ""
exit 0 exit 0
;; ;;
esac esac
# Remove Homebrew installation (silent)
local has_error=false local has_error=false
if [[ "$is_homebrew" == "true" ]]; then if [[ "$is_homebrew" == "true" ]]; then
if ! brew uninstall mole > /dev/null 2>&1; then if [[ -z "$brew_cmd" ]]; then
log_error "Homebrew command not found. Please ensure Homebrew is installed and in your PATH."
log_warning "You may need to manually run: brew uninstall --force mole"
exit 1
fi
log_admin "Attempting to uninstall Mole via Homebrew..."
local brew_uninstall_output
if ! brew_uninstall_output=$("$brew_cmd" uninstall --force mole 2>&1); then
has_error=true has_error=true
log_error "Homebrew uninstallation failed:"
printf "%s\n" "$brew_uninstall_output" | sed "s/^/${RED} | ${NC}/" >&2
log_warning "Please manually run: ${YELLOW}brew uninstall --force mole${NC}"
echo "" # Add a blank line for readability
else
log_success "Mole uninstalled via Homebrew."
fi fi
fi fi
# Remove manual installations
if [[ ${manual_count:-0} -gt 0 ]]; then if [[ ${manual_count:-0} -gt 0 ]]; then
for install in "${manual_installs[@]}"; do for install in "${manual_installs[@]}"; do
if [[ -f "$install" ]]; then if [[ -f "$install" ]]; then
# Check if directory requires sudo (deletion is a directory operation)
if [[ ! -w "$(dirname "$install")" ]]; then if [[ ! -w "$(dirname "$install")" ]]; then
# Requires sudo
if ! sudo rm -f "$install" 2> /dev/null; then if ! sudo rm -f "$install" 2> /dev/null; then
has_error=true has_error=true
fi fi
else else
# Regular user permission
if ! rm -f "$install" 2> /dev/null; then if ! rm -f "$install" 2> /dev/null; then
has_error=true has_error=true
fi fi
@@ -541,25 +556,25 @@ remove_mole() {
if [[ ${alias_count:-0} -gt 0 ]]; then if [[ ${alias_count:-0} -gt 0 ]]; then
for alias in "${alias_installs[@]}"; do for alias in "${alias_installs[@]}"; do
if [[ -f "$alias" ]]; then if [[ -f "$alias" ]]; then
# Check if directory requires sudo
if [[ ! -w "$(dirname "$alias")" ]]; then if [[ ! -w "$(dirname "$alias")" ]]; then
sudo rm -f "$alias" 2> /dev/null || true if ! sudo rm -f "$alias" 2> /dev/null; then
has_error=true
fi
else else
rm -f "$alias" 2> /dev/null || true if ! rm -f "$alias" 2> /dev/null; then
has_error=true
fi
fi fi
fi fi
done done
fi fi
# Clean up cache first (silent)
if [[ -d "$HOME/.cache/mole" ]]; then if [[ -d "$HOME/.cache/mole" ]]; then
rm -rf "$HOME/.cache/mole" 2> /dev/null || true rm -rf "$HOME/.cache/mole" 2> /dev/null || true
fi fi
# Clean up configuration last (silent)
if [[ -d "$HOME/.config/mole" ]]; then if [[ -d "$HOME/.config/mole" ]]; then
rm -rf "$HOME/.config/mole" 2> /dev/null || true rm -rf "$HOME/.config/mole" 2> /dev/null || true
fi fi
# Show final result
local final_message local final_message
if [[ "$has_error" == "true" ]]; then if [[ "$has_error" == "true" ]]; then
final_message="${YELLOW}${ICON_ERROR} Mole uninstalled with some errors, thank you for using Mole!${NC}" final_message="${YELLOW}${ICON_ERROR} Mole uninstalled with some errors, thank you for using Mole!${NC}"
@@ -571,38 +586,33 @@ remove_mole() {
exit 0 exit 0
} }
# Display main menu options with minimal refresh to avoid flicker # Menu UI
show_main_menu() { show_main_menu() {
local selected="${1:-1}" local selected="${1:-1}"
local _full_draw="${2:-true}" # Kept for compatibility (unused) local _full_draw="${2:-true}" # Kept for compatibility (unused)
local banner="${MAIN_MENU_BANNER:-}" local banner="${MAIN_MENU_BANNER:-}"
local update_message="${MAIN_MENU_UPDATE_MESSAGE:-}" local update_message="${MAIN_MENU_UPDATE_MESSAGE:-}"
# Fallback if globals missing (should not happen)
if [[ -z "$banner" ]]; then if [[ -z "$banner" ]]; then
banner="$(show_brand_banner)" banner="$(show_brand_banner)"
MAIN_MENU_BANNER="$banner" MAIN_MENU_BANNER="$banner"
fi fi
printf '\033[H' # Move cursor to home printf '\033[H'
local line="" local line=""
# Leading spacer
printf '\r\033[2K\n' printf '\r\033[2K\n'
# Brand banner
while IFS= read -r line || [[ -n "$line" ]]; do while IFS= read -r line || [[ -n "$line" ]]; do
printf '\r\033[2K%s\n' "$line" printf '\r\033[2K%s\n' "$line"
done <<< "$banner" done <<< "$banner"
# Update notification block (if present)
if [[ -n "$update_message" ]]; then if [[ -n "$update_message" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do while IFS= read -r line || [[ -n "$line" ]]; do
printf '\r\033[2K%s\n' "$line" printf '\r\033[2K%s\n' "$line"
done <<< "$update_message" done <<< "$update_message"
fi fi
# Spacer before menu options
printf '\r\033[2K\n' printf '\r\033[2K\n'
printf '\r\033[2K%s\n' "$(show_menu_option 1 "Clean Free up disk space" "$([[ $selected -eq 1 ]] && echo true || echo false)")" printf '\r\033[2K%s\n' "$(show_menu_option 1 "Clean Free up disk space" "$([[ $selected -eq 1 ]] && echo true || echo false)")"
@@ -613,7 +623,6 @@ show_main_menu() {
if [[ -t 0 ]]; then if [[ -t 0 ]]; then
printf '\r\033[2K\n' printf '\r\033[2K\n'
# Show TouchID if not configured, otherwise show Update
local controls="${GRAY}↑↓ | Enter | M More | " local controls="${GRAY}↑↓ | Enter | M More | "
if ! is_touchid_configured; then if ! is_touchid_configured; then
controls="${controls}T TouchID" controls="${controls}T TouchID"
@@ -625,24 +634,21 @@ show_main_menu() {
printf '\r\033[2K\n' printf '\r\033[2K\n'
fi fi
# Clear any remaining content below without full screen wipe
printf '\033[J' printf '\033[J'
} }
# Interactive main menu loop
interactive_main_menu() { interactive_main_menu() {
# Show intro animation only once per terminal tab
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
local tty_name local tty_name
tty_name=$(tty 2> /dev/null || echo "") tty_name=$(tty 2> /dev/null || echo "")
if [[ -n "$tty_name" ]]; then if [[ -n "$tty_name" ]]; then
local flag_file local flag_file
local cache_dir="$HOME/.cache/mole" local cache_dir="$HOME/.cache/mole"
mkdir -p "$cache_dir" 2> /dev/null ensure_user_dir "$cache_dir"
flag_file="$cache_dir/intro_$(echo "$tty_name" | tr -c '[:alnum:]_' '_')" flag_file="$cache_dir/intro_$(echo "$tty_name" | tr -c '[:alnum:]_' '_')"
if [[ ! -f "$flag_file" ]]; then if [[ ! -f "$flag_file" ]]; then
animate_mole_intro animate_mole_intro
touch "$flag_file" 2> /dev/null || true ensure_user_file "$flag_file"
fi fi
fi fi
fi fi
@@ -737,13 +743,12 @@ interactive_main_menu() {
"QUIT") cleanup_and_exit ;; "QUIT") cleanup_and_exit ;;
esac esac
# Drain any accumulated input after processing (e.g., touchpad scroll events)
drain_pending_input drain_pending_input
done done
} }
# CLI dispatch
main() { main() {
# Parse global flags
local -a args=() local -a args=()
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
@@ -772,9 +777,15 @@ main() {
"status") "status")
exec "$SCRIPT_DIR/bin/status.sh" "${args[@]:1}" exec "$SCRIPT_DIR/bin/status.sh" "${args[@]:1}"
;; ;;
"purge")
exec "$SCRIPT_DIR/bin/purge.sh" "${args[@]:1}"
;;
"touchid") "touchid")
exec "$SCRIPT_DIR/bin/touchid.sh" "${args[@]:1}" exec "$SCRIPT_DIR/bin/touchid.sh" "${args[@]:1}"
;; ;;
"completion")
exec "$SCRIPT_DIR/bin/completion.sh" "${args[@]:1}"
;;
"update") "update")
update_mole update_mole
exit 0 exit 0

View File

@@ -1,51 +0,0 @@
#!/bin/bash
# Build Universal Binary for analyze-go
# Supports both Apple Silicon and Intel Macs
set -euo pipefail
cd "$(dirname "$0")/.."
# Check if Go is installed
if ! command -v go > /dev/null 2>&1; then
echo "Error: Go not installed"
echo "Install: brew install go"
exit 1
fi
echo "Building analyze-go for multiple architectures..."
# Get version info
VERSION=$(git describe --tags --always --dirty 2> /dev/null || echo "dev")
BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S')
LDFLAGS="-s -w -X main.Version=$VERSION -X main.BuildTime=$BUILD_TIME"
echo " Version: $VERSION"
echo " Build time: $BUILD_TIME"
echo ""
# Build for arm64 (Apple Silicon)
echo " → Building for arm64..."
GOARCH=arm64 go build -ldflags="$LDFLAGS" -trimpath -o bin/analyze-go-arm64 ./cmd/analyze
# Build for amd64 (Intel)
echo " → Building for amd64..."
GOARCH=amd64 go build -ldflags="$LDFLAGS" -trimpath -o bin/analyze-go-amd64 ./cmd/analyze
# Create Universal Binary
echo " → Creating Universal Binary..."
lipo -create bin/analyze-go-arm64 bin/analyze-go-amd64 -output bin/analyze-go
# Clean up temporary files
rm bin/analyze-go-arm64 bin/analyze-go-amd64
# Verify
echo ""
echo "✓ Build complete!"
echo ""
file bin/analyze-go
size_bytes=$(stat -f%z bin/analyze-go 2> /dev/null || echo 0)
size_mb=$((size_bytes / 1024 / 1024))
printf "Size: %d MB (%d bytes)\n" "$size_mb" "$size_bytes"
echo ""
echo "Binary supports: arm64 (Apple Silicon) + x86_64 (Intel)"

View File

@@ -1,44 +0,0 @@
#!/bin/bash
# Build Universal Binary for status-go
# Supports both Apple Silicon and Intel Macs
set -euo pipefail
cd "$(dirname "$0")/.."
if ! command -v go > /dev/null 2>&1; then
echo "Error: Go not installed"
echo "Install: brew install go"
exit 1
fi
echo "Building status-go for multiple architectures..."
VERSION=$(git describe --tags --always --dirty 2> /dev/null || echo "dev")
BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S')
LDFLAGS="-s -w -X main.Version=$VERSION -X main.BuildTime=$BUILD_TIME"
echo " Version: $VERSION"
echo " Build time: $BUILD_TIME"
echo ""
echo " → Building for arm64..."
GOARCH=arm64 go build -ldflags="$LDFLAGS" -trimpath -o bin/status-go-arm64 ./cmd/status
echo " → Building for amd64..."
GOARCH=amd64 go build -ldflags="$LDFLAGS" -trimpath -o bin/status-go-amd64 ./cmd/status
echo " → Creating Universal Binary..."
lipo -create bin/status-go-arm64 bin/status-go-amd64 -output bin/status-go
rm bin/status-go-arm64 bin/status-go-amd64
echo ""
echo "✓ Build complete!"
echo ""
file bin/status-go
size_bytes=$(stat -f%z bin/status-go 2> /dev/null || echo 0)
size_mb=$((size_bytes / 1024 / 1024))
printf "Size: %d MB (%d bytes)\n" "$size_mb" "$size_bytes"
echo ""
echo "Binary supports: arm64 (Apple Silicon) + x86_64 (Intel)"

View File

@@ -1,126 +1,189 @@
#!/bin/bash #!/bin/bash
# Unified check script for Mole project # Code quality checks for Mole.
# Runs all quality checks in one command # Auto-formats code, then runs lint and syntax checks.
set -e set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Colors MODE="all"
usage() {
cat << 'EOF'
Usage: ./scripts/check.sh [--format|--no-format]
Options:
--format Apply formatting fixes only (shfmt, gofmt)
--no-format Skip formatting and run checks only
--help Show this help
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--format)
MODE="format"
shift
;;
--no-format)
MODE="check"
shift
;;
--help | -h)
usage
exit 0
;;
*)
echo "Unknown option: $1"
usage
exit 1
;;
esac
done
cd "$PROJECT_ROOT"
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' NC='\033[0m'
cd "$PROJECT_ROOT" readonly ICON_SUCCESS="✓"
readonly ICON_ERROR="☻"
readonly ICON_WARNING="●"
readonly ICON_LIST="•"
echo -e "${BLUE}=== Running Mole Quality Checks ===${NC}\n" echo -e "${BLUE}=== Mole Check (${MODE}) ===${NC}\n"
# 1. Format check SHELL_FILES=$(find . -type f \( -name "*.sh" -o -name "mole" \) \
echo -e "${YELLOW}1. Checking code formatting...${NC}" -not -path "./.git/*" \
if command -v shfmt > /dev/null 2>&1; then -not -path "*/node_modules/*" \
if ./scripts/format.sh --check; then -not -path "*/tests/tmp-*/*" \
echo -e "${GREEN}✓ Formatting check passed${NC}\n" -not -path "*/.*" \
2> /dev/null)
if [[ "$MODE" == "format" ]]; then
echo -e "${YELLOW}Formatting shell scripts...${NC}"
if command -v shfmt > /dev/null 2>&1; then
echo "$SHELL_FILES" | xargs shfmt -i 4 -ci -sr -w
echo -e "${GREEN}${ICON_SUCCESS} Shell formatting complete${NC}\n"
else else
echo -e "${RED}✗ Formatting check failed${NC}\n" echo -e "${RED}${ICON_ERROR} shfmt not installed${NC}"
exit 1 exit 1
fi fi
else
echo -e "${YELLOW}⚠ shfmt not installed, skipping format check${NC}\n" if command -v go > /dev/null 2>&1; then
echo -e "${YELLOW}Formatting Go code...${NC}"
gofmt -w ./cmd
echo -e "${GREEN}${ICON_SUCCESS} Go formatting complete${NC}\n"
else
echo -e "${YELLOW}${ICON_WARNING} go not installed, skipping gofmt${NC}\n"
fi
echo -e "${GREEN}=== Format Completed ===${NC}"
exit 0
fi fi
# 2. ShellCheck if [[ "$MODE" != "check" ]]; then
echo -e "${YELLOW}2. Running ShellCheck...${NC}" echo -e "${YELLOW}1. Formatting shell scripts...${NC}"
if command -v shfmt > /dev/null 2>&1; then
echo "$SHELL_FILES" | xargs shfmt -i 4 -ci -sr -w
echo -e "${GREEN}${ICON_SUCCESS} Shell formatting applied${NC}\n"
else
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}"
gofmt -w ./cmd
echo -e "${GREEN}${ICON_SUCCESS} Go formatting applied${NC}\n"
fi
fi
echo -e "${YELLOW}3. Running ShellCheck...${NC}"
if command -v shellcheck > /dev/null 2>&1; then if command -v shellcheck > /dev/null 2>&1; then
# Count total files if shellcheck mole bin/*.sh lib/*/*.sh scripts/*.sh; then
SHELL_FILES=$(find . -type f \( -name "*.sh" -o -name "mole" \) -not -path "./tests/*" -not -path "./.git/*") echo -e "${GREEN}${ICON_SUCCESS} ShellCheck passed${NC}\n"
FILE_COUNT=$(echo "$SHELL_FILES" | wc -l | tr -d ' ')
if shellcheck mole bin/*.sh lib/*.sh scripts/*.sh 2>&1 | grep -q "SC[0-9]"; then
echo -e "${YELLOW}⚠ ShellCheck found some issues (non-critical):${NC}"
shellcheck mole bin/*.sh lib/*.sh scripts/*.sh 2>&1 | head -20
echo -e "${GREEN}✓ ShellCheck completed (${FILE_COUNT} files checked)${NC}\n"
else else
echo -e "${GREEN}✓ ShellCheck passed (${FILE_COUNT} files checked)${NC}\n" echo -e "${RED}${ICON_ERROR} ShellCheck failed${NC}\n"
fi
else
echo -e "${YELLOW}⚠ shellcheck not installed, skipping${NC}\n"
fi
# 3. Unit tests (if available)
echo -e "${YELLOW}3. Running tests...${NC}"
if command -v bats > /dev/null 2>&1 && [ -d "tests" ]; then
if bats tests/*.bats; then
echo -e "${GREEN}✓ Tests passed${NC}\n"
else
echo -e "${RED}✗ Tests failed (see output above)${NC}\n"
exit 1 exit 1
fi fi
else else
echo -e "${YELLOW}⚠ bats not installed or no tests found, skipping${NC}\n" echo -e "${YELLOW}${ICON_WARNING} shellcheck not installed, skipping${NC}\n"
fi fi
# 4. Code optimization checks echo -e "${YELLOW}4. Running syntax check...${NC}"
echo -e "${YELLOW}4. Checking code optimizations...${NC}" if ! bash -n mole; then
echo -e "${RED}${ICON_ERROR} Syntax check failed (mole)${NC}\n"
exit 1
fi
for script in bin/*.sh; do
if ! bash -n "$script"; then
echo -e "${RED}${ICON_ERROR} Syntax check failed ($script)${NC}\n"
exit 1
fi
done
find lib -name "*.sh" | while read -r script; do
if ! bash -n "$script"; then
echo -e "${RED}${ICON_ERROR} Syntax check failed ($script)${NC}\n"
exit 1
fi
done
echo -e "${GREEN}${ICON_SUCCESS} Syntax check passed${NC}\n"
echo -e "${YELLOW}5. Checking optimizations...${NC}"
OPTIMIZATION_SCORE=0 OPTIMIZATION_SCORE=0
TOTAL_CHECKS=0 TOTAL_CHECKS=0
# Check 1: Keyboard input handling (restored to 1s for reliability)
((TOTAL_CHECKS++)) ((TOTAL_CHECKS++))
if grep -q "read -r -s -n 1 -t 1" lib/core/ui.sh; then if grep -q "read -r -s -n 1 -t 1" lib/core/ui.sh; then
echo -e "${GREEN} Keyboard timeout properly configured (1s)${NC}" echo -e "${GREEN} ${ICON_SUCCESS} Keyboard timeout configured${NC}"
((OPTIMIZATION_SCORE++)) ((OPTIMIZATION_SCORE++))
else else
echo -e "${YELLOW} Keyboard timeout may be misconfigured${NC}" echo -e "${YELLOW} ${ICON_WARNING} Keyboard timeout may be misconfigured${NC}"
fi fi
# Check 2: Single-pass drain_pending_input
((TOTAL_CHECKS++)) ((TOTAL_CHECKS++))
DRAIN_PASSES=$(grep -c "while IFS= read -r -s -n 1" lib/core/ui.sh 2> /dev/null || true) DRAIN_PASSES=$(grep -c "while IFS= read -r -s -n 1" lib/core/ui.sh 2> /dev/null || true)
DRAIN_PASSES=${DRAIN_PASSES:-0} DRAIN_PASSES=${DRAIN_PASSES:-0}
if [[ $DRAIN_PASSES -eq 1 ]]; then if [[ $DRAIN_PASSES -eq 1 ]]; then
echo -e "${GREEN} drain_pending_input optimized (single-pass)${NC}" echo -e "${GREEN} ${ICON_SUCCESS} drain_pending_input optimized${NC}"
((OPTIMIZATION_SCORE++)) ((OPTIMIZATION_SCORE++))
else else
echo -e "${YELLOW} drain_pending_input has multiple passes${NC}" echo -e "${YELLOW} ${ICON_WARNING} drain_pending_input has multiple passes${NC}"
fi fi
# Check 3: Log rotation once per session
((TOTAL_CHECKS++)) ((TOTAL_CHECKS++))
if grep -q "rotate_log_once" lib/core/log.sh; then if grep -q "rotate_log_once" lib/core/log.sh; then
echo -e "${GREEN} Log rotation optimized (once per session)${NC}" echo -e "${GREEN} ${ICON_SUCCESS} Log rotation optimized${NC}"
((OPTIMIZATION_SCORE++)) ((OPTIMIZATION_SCORE++))
else else
echo -e "${YELLOW} Log rotation not optimized${NC}" echo -e "${YELLOW} ${ICON_WARNING} Log rotation not optimized${NC}"
fi fi
# Check 4: Simplified cache validation
((TOTAL_CHECKS++)) ((TOTAL_CHECKS++))
if ! grep -q "cache_meta\|cache_dir_mtime" bin/uninstall.sh; then if ! grep -q "cache_meta\|cache_dir_mtime" bin/uninstall.sh; then
echo -e "${GREEN} Cache validation simplified${NC}" echo -e "${GREEN} ${ICON_SUCCESS} Cache validation simplified${NC}"
((OPTIMIZATION_SCORE++)) ((OPTIMIZATION_SCORE++))
else else
echo -e "${YELLOW} Cache still uses redundant metadata${NC}" echo -e "${YELLOW} ${ICON_WARNING} Cache still uses redundant metadata${NC}"
fi fi
# Check 5: Stricter path validation
((TOTAL_CHECKS++)) ((TOTAL_CHECKS++))
if grep -q "Consecutive slashes" bin/clean.sh; then if grep -q "Consecutive slashes" bin/clean.sh; then
echo -e "${GREEN} Path validation enhanced${NC}" echo -e "${GREEN} ${ICON_SUCCESS} Path validation enhanced${NC}"
((OPTIMIZATION_SCORE++)) ((OPTIMIZATION_SCORE++))
else else
echo -e "${YELLOW} Path validation not enhanced${NC}" echo -e "${YELLOW} ${ICON_WARNING} Path validation not enhanced${NC}"
fi fi
echo -e "${BLUE} Optimization score: $OPTIMIZATION_SCORE/$TOTAL_CHECKS${NC}\n" echo -e "${BLUE} Optimization score: $OPTIMIZATION_SCORE/$TOTAL_CHECKS${NC}\n"
# Summary echo -e "${GREEN}=== Checks Completed ===${NC}"
echo -e "${GREEN}=== All Checks Completed ===${NC}"
if [[ $OPTIMIZATION_SCORE -eq $TOTAL_CHECKS ]]; then if [[ $OPTIMIZATION_SCORE -eq $TOTAL_CHECKS ]]; then
echo -e "${GREEN}✓ Code quality checks passed!${NC}" echo -e "${GREEN}${ICON_SUCCESS} All optimizations applied${NC}"
echo -e "${GREEN}✓ All optimizations applied!${NC}"
else else
echo -e "${YELLOW}⚠ Code quality checks passed, but some optimizations missing${NC}" echo -e "${YELLOW}${ICON_WARNING} Some optimizations missing${NC}"
fi fi

View File

@@ -1,73 +0,0 @@
#!/bin/bash
# Format all shell scripts in the Mole project
#
# Usage:
# ./scripts/format.sh # Format all scripts
# ./scripts/format.sh --check # Check only, don't modify
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CHECK_ONLY=false
# Parse arguments
if [[ "${1:-}" == "--check" ]]; then
CHECK_ONLY=true
elif [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
cat << 'EOF'
Usage: ./scripts/format.sh [--check]
Format shell scripts using shfmt.
Options:
--check Check formatting without modifying files
--help Show this help
Install: brew install shfmt
EOF
exit 0
fi
# Check if shfmt is installed
if ! command -v shfmt > /dev/null 2>&1; then
echo "Error: shfmt not installed"
echo "Install: brew install shfmt"
exit 1
fi
# Find all shell scripts (excluding temp directories and build artifacts)
cd "$PROJECT_ROOT"
# Build list of files to format (exclude .git, node_modules, tmp directories)
FILES=$(find . -type f \( -name "*.sh" -o -name "mole" \) \
-not -path "./.git/*" \
-not -path "*/node_modules/*" \
-not -path "*/tests/tmp-*/*" \
-not -path "*/.*" \
2> /dev/null)
if [[ -z "$FILES" ]]; then
echo "No shell scripts found"
exit 0
fi
# shfmt options: -i 4 (4 spaces), -ci (indent switch cases), -sr (space after redirect)
if [[ "$CHECK_ONLY" == "true" ]]; then
echo "Checking formatting..."
if echo "$FILES" | xargs shfmt -i 4 -ci -sr -d > /dev/null 2>&1; then
echo "✓ All scripts properly formatted"
exit 0
else
echo "✗ Some scripts need formatting:"
echo "$FILES" | xargs shfmt -i 4 -ci -sr -d
echo ""
echo "Run './scripts/format.sh' to fix"
exit 1
fi
else
echo "Formatting scripts..."
echo "$FILES" | xargs shfmt -i 4 -ci -sr -w
echo "✓ Done"
fi

View File

@@ -1,115 +0,0 @@
#!/bin/bash
# Quick test runner script
# Runs all tests before committing
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/.."
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo "==============================="
echo " Mole Test Runner"
echo "==============================="
echo ""
# Track failures
FAILED=0
# 1. ShellCheck
echo "1. Running ShellCheck..."
if command -v shellcheck > /dev/null 2>&1; then
if shellcheck mole bin/*.sh 2> /dev/null &&
find lib -name "*.sh" -type f -exec shellcheck {} + 2> /dev/null; then
printf "${GREEN}✓ ShellCheck passed${NC}\n"
else
printf "${RED}✗ ShellCheck failed${NC}\n"
((FAILED++))
fi
else
printf "${YELLOW}⚠ ShellCheck not installed, skipping${NC}\n"
fi
echo ""
# 2. Syntax Check
echo "2. Running syntax check..."
if bash -n mole &&
bash -n bin/*.sh 2> /dev/null &&
find lib -name "*.sh" -type f -exec bash -n {} \; 2> /dev/null; then
printf "${GREEN}✓ Syntax check passed${NC}\n"
else
printf "${RED}✗ Syntax check failed${NC}\n"
((FAILED++))
fi
echo ""
# 3. Unit Tests
echo "3. Running unit tests..."
if command -v bats > /dev/null 2>&1; then
# Note: bats might detect non-TTY and suppress color.
# Adding --tap prevents spinner issues in background.
if bats tests/*.bats; then
printf "${GREEN}✓ Unit tests passed${NC}\n"
else
printf "${RED}✗ Unit tests failed${NC}\n"
((FAILED++))
fi
else
printf "${YELLOW}⚠ Bats not installed, skipping unit tests${NC}\n"
echo " Install with: brew install bats-core"
fi
echo ""
# 4. Go Tests
echo "4. Running Go tests..."
if command -v go > /dev/null 2>&1; then
if go build ./... && go vet ./cmd/... && go test ./cmd/...; then
printf "${GREEN}✓ Go tests passed${NC}\n"
else
printf "${RED}✗ Go tests failed${NC}\n"
((FAILED++))
fi
else
printf "${YELLOW}⚠ Go not installed, skipping Go tests${NC}\n"
fi
echo ""
# 5. Module Loading Test
echo "5. Testing module loading..."
if bash -c 'source lib/core/common.sh && echo "OK"' > /dev/null 2>&1; then
printf "${GREEN}✓ Module loading passed${NC}\n"
else
printf "${RED}✗ Module loading failed${NC}\n"
((FAILED++))
fi
echo ""
# 6. Integration Tests
echo "6. Running integration tests..."
export MOLE_MAX_PARALLEL_JOBS=30
if ./bin/clean.sh --dry-run > /dev/null 2>&1; then
printf "${GREEN}✓ Clean dry-run passed${NC}\n"
else
printf "${RED}✗ Clean dry-run failed${NC}\n"
((FAILED++))
fi
echo ""
# Summary
echo "==============================="
if [[ $FAILED -eq 0 ]]; then
printf "${GREEN}All tests passed!${NC}\n"
echo ""
echo "You can now commit your changes."
exit 0
else
printf "${RED}$FAILED test(s) failed!${NC}\n"
echo ""
echo "Please fix the failing tests before committing."
exit 1
fi

View File

@@ -18,7 +18,16 @@ log_step() { echo -e "${BLUE}${ICON_STEP}${NC} $1"; }
log_success() { echo -e "${GREEN}${ICON_SUCCESS}${NC} $1"; } log_success() { echo -e "${GREEN}${ICON_SUCCESS}${NC} $1"; }
log_warn() { echo -e "${YELLOW}${ICON_WARN}${NC} $1"; } log_warn() { echo -e "${YELLOW}${ICON_WARN}${NC} $1"; }
log_error() { echo -e "${RED}${ICON_ERR}${NC} $1"; } log_error() { echo -e "${RED}${ICON_ERR}${NC} $1"; }
log_header() { echo -e "\n${BLUE}==== $1 ====${NC}\n"; }
is_interactive() { [[ -t 1 && -r /dev/tty ]]; }
prompt_enter() {
local prompt="$1"
if is_interactive; then
read -r -p "$prompt" < /dev/tty || true
else
echo "$prompt"
fi
}
detect_mo() { detect_mo() {
if command -v mo > /dev/null 2>&1; then if command -v mo > /dev/null 2>&1; then
command -v mo command -v mo
@@ -223,42 +232,44 @@ EOF
create_raycast_commands() { create_raycast_commands() {
local mo_bin="$1" local mo_bin="$1"
local default_dir="$HOME/Library/Application Support/Raycast/script-commands" local default_dir="$HOME/Library/Application Support/Raycast/script-commands"
local alt_dir="$HOME/Documents/Raycast/Scripts" local dir="$default_dir"
local dirs=()
if [[ -d "$default_dir" ]]; then
dirs+=("$default_dir")
fi
if [[ -d "$alt_dir" ]]; then
dirs+=("$alt_dir")
fi
if [[ ${#dirs[@]} -eq 0 ]]; then
dirs+=("$default_dir")
fi
log_step "Installing Raycast commands..." log_step "Installing Raycast commands..."
for dir in "${dirs[@]}"; do mkdir -p "$dir"
mkdir -p "$dir" write_raycast_script "$dir/mole-clean.sh" "clean" "$mo_bin" "clean"
write_raycast_script "$dir/mole-clean.sh" "clean" "$mo_bin" "clean" write_raycast_script "$dir/mole-uninstall.sh" "uninstall" "$mo_bin" "uninstall"
write_raycast_script "$dir/mole-uninstall.sh" "uninstall" "$mo_bin" "uninstall" write_raycast_script "$dir/mole-optimize.sh" "optimize" "$mo_bin" "optimize"
write_raycast_script "$dir/mole-optimize.sh" "optimize" "$mo_bin" "optimize" write_raycast_script "$dir/mole-analyze.sh" "analyze" "$mo_bin" "analyze"
write_raycast_script "$dir/mole-analyze.sh" "analyze" "$mo_bin" "analyze" write_raycast_script "$dir/mole-status.sh" "status" "$mo_bin" "status"
write_raycast_script "$dir/mole-status.sh" "status" "$mo_bin" "status" log_success "Scripts ready in: $dir"
log_success "Scripts ready in: $dir"
done
echo "" log_header "Raycast Configuration"
if open "raycast://extensions/script-commands" > /dev/null 2>&1; then if command -v open > /dev/null 2>&1; then
log_step "Raycast settings opened." if open "raycast://extensions/raycast/raycast-settings/extensions" > /dev/null 2>&1; then
log_step "Raycast settings opened."
else
log_warn "Could not auto-open Raycast."
fi
else else
log_warn "Could not auto-open Raycast." log_warn "open command not available; please open Raycast manually."
fi fi
echo "" echo "If Raycast asks to add a Script Directory, use:"
echo "Next steps to activate Raycast commands:" echo " $dir"
echo " 1. Open Raycast (⌘ Space)"
echo " 2. Search for 'Reload Script Directories'" if is_interactive; then
echo " 3. Press Enter to load new commands" log_header "Finalizing Setup"
prompt_enter "Press [Enter] to reload script directories in Raycast..."
if command -v open > /dev/null 2>&1 && open "raycast://extensions/raycast/raycast/reload-script-directories" > /dev/null 2>&1; then
log_step "Raycast script directories reloaded."
else
log_warn "Could not auto-reload Raycast script directories."
fi
log_success "Raycast setup complete!"
else
log_warn "Non-interactive mode; skip Raycast reload. Please run 'Reload Script Directories' in Raycast."
fi
} }
uuid() { uuid() {
@@ -277,7 +288,6 @@ create_alfred_workflow() {
local workflows_dir="$prefs_dir/workflows" local workflows_dir="$prefs_dir/workflows"
if [[ ! -d "$workflows_dir" ]]; then if [[ ! -d "$workflows_dir" ]]; then
log_warn "Alfred preferences not found at $workflows_dir. Skipping Alfred workflow."
return return
fi fi

128
scripts/test.sh Executable file
View File

@@ -0,0 +1,128 @@
#!/bin/bash
# Test runner for Mole.
# Runs unit, Go, and integration tests.
# Exits non-zero on failures.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
# shellcheck source=lib/core/file_ops.sh
source "$PROJECT_ROOT/lib/core/file_ops.sh"
echo "==============================="
echo "Mole Test Runner"
echo "==============================="
echo ""
FAILED=0
echo "1. Linting test scripts..."
if command -v shellcheck > /dev/null 2>&1; then
TEST_FILES=()
while IFS= read -r file; do
TEST_FILES+=("$file")
done < <(find tests -type f \( -name '*.bats' -o -name '*.sh' \) | sort)
if [[ ${#TEST_FILES[@]} -gt 0 ]]; then
if shellcheck --rcfile "$PROJECT_ROOT/.shellcheckrc" "${TEST_FILES[@]}"; then
printf "${GREEN}${ICON_SUCCESS} Test script lint passed${NC}\n"
else
printf "${RED}${ICON_ERROR} Test script lint failed${NC}\n"
((FAILED++))
fi
else
printf "${YELLOW}${ICON_WARNING} No test scripts found, skipping${NC}\n"
fi
else
printf "${YELLOW}${ICON_WARNING} shellcheck not installed, skipping${NC}\n"
fi
echo ""
echo "2. Running unit tests..."
if command -v bats > /dev/null 2>&1 && [ -d "tests" ]; then
if [[ -z "${TERM:-}" ]]; then
export TERM="xterm-256color"
fi
if [[ $# -eq 0 ]]; then
set -- tests
fi
if [[ -t 1 ]]; then
if bats -p "$@" | sed -e 's/^ok /OK /' -e 's/^not ok /FAIL /'; then
printf "${GREEN}${ICON_SUCCESS} Unit tests passed${NC}\n"
else
printf "${RED}${ICON_ERROR} Unit tests failed${NC}\n"
((FAILED++))
fi
else
if TERM="${TERM:-xterm-256color}" bats --tap "$@" | sed -e 's/^ok /OK /' -e 's/^not ok /FAIL /'; then
printf "${GREEN}${ICON_SUCCESS} Unit tests passed${NC}\n"
else
printf "${RED}${ICON_ERROR} Unit tests failed${NC}\n"
((FAILED++))
fi
fi
else
printf "${YELLOW}${ICON_WARNING} bats not installed or no tests found, skipping${NC}\n"
fi
echo ""
echo "3. Running Go tests..."
if command -v go > /dev/null 2>&1; then
if go build ./... > /dev/null 2>&1 && go vet ./cmd/... > /dev/null 2>&1 && go test ./cmd/... > /dev/null 2>&1; then
printf "${GREEN}${ICON_SUCCESS} Go tests passed${NC}\n"
else
printf "${RED}${ICON_ERROR} Go tests failed${NC}\n"
((FAILED++))
fi
else
printf "${YELLOW}${ICON_WARNING} Go not installed, skipping Go tests${NC}\n"
fi
echo ""
echo "4. Testing module loading..."
if bash -c 'source lib/core/common.sh && echo "OK"' > /dev/null 2>&1; then
printf "${GREEN}${ICON_SUCCESS} Module loading passed${NC}\n"
else
printf "${RED}${ICON_ERROR} Module loading failed${NC}\n"
((FAILED++))
fi
echo ""
echo "5. Running integration tests..."
# Quick syntax check for main scripts
if bash -n mole && bash -n bin/clean.sh && bash -n bin/optimize.sh; then
printf "${GREEN}${ICON_SUCCESS} Integration tests passed${NC}\n"
else
printf "${RED}${ICON_ERROR} Integration tests failed${NC}\n"
((FAILED++))
fi
echo ""
echo "6. Testing installation..."
# Skip if Homebrew mole is installed (install.sh will refuse to overwrite)
if brew list mole &> /dev/null; then
printf "${GREEN}${ICON_SUCCESS} Installation test skipped (Homebrew)${NC}\n"
elif ./install.sh --prefix /tmp/mole-test > /dev/null 2>&1; then
if [ -f /tmp/mole-test/mole ]; then
printf "${GREEN}${ICON_SUCCESS} Installation test passed${NC}\n"
else
printf "${RED}${ICON_ERROR} Installation test failed${NC}\n"
((FAILED++))
fi
else
printf "${RED}${ICON_ERROR} Installation test failed${NC}\n"
((FAILED++))
fi
safe_remove "/tmp/mole-test" true || true
echo ""
echo "==============================="
if [[ $FAILED -eq 0 ]]; then
printf "${GREEN}${ICON_SUCCESS} All tests passed!${NC}\n"
exit 0
fi
printf "${RED}${ICON_ERROR} $FAILED test(s) failed!${NC}\n"
exit 1

72
tests/app_caches.bats Normal file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bats
setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-app-caches.XXXXXX")"
export HOME
mkdir -p "$HOME"
}
teardown_file() {
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
}
@test "clean_xcode_tools skips derived data when Xcode running" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
pgrep() { return 0; }
safe_clean() { echo "$2"; }
clean_xcode_tools
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Xcode is running"* ]]
[[ "$output" != *"derived data"* ]]
[[ "$output" != *"archives"* ]]
}
@test "clean_media_players protects spotify offline cache" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
mkdir -p "$HOME/Library/Application Support/Spotify/PersistentCache/Storage"
touch "$HOME/Library/Application Support/Spotify/PersistentCache/Storage/offline.bnk"
safe_clean() { echo "CLEAN:$2"; }
clean_media_players
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Spotify cache protected"* ]]
[[ "$output" != *"CLEAN: Spotify cache"* ]]
}
@test "clean_user_gui_applications calls all sections" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
stop_section_spinner() { :; }
safe_clean() { :; }
clean_xcode_tools() { echo "xcode"; }
clean_code_editors() { echo "editors"; }
clean_communication_apps() { echo "comm"; }
clean_user_gui_applications
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"xcode"* ]]
[[ "$output" == *"editors"* ]]
[[ "$output" == *"comm"* ]]
}

113
tests/app_caches_more.bats Normal file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env bats
setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-app-caches-more.XXXXXX")"
export HOME
mkdir -p "$HOME"
}
teardown_file() {
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
}
@test "clean_ai_apps calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_ai_apps
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"ChatGPT cache"* ]]
[[ "$output" == *"Claude desktop cache"* ]]
}
@test "clean_design_tools calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_design_tools
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Sketch cache"* ]]
[[ "$output" == *"Figma cache"* ]]
}
@test "clean_dingtalk calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_dingtalk
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"DingTalk iDingTalk cache"* ]]
[[ "$output" == *"DingTalk logs"* ]]
}
@test "clean_download_managers calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_download_managers
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Aria2 cache"* ]]
[[ "$output" == *"qBittorrent cache"* ]]
}
@test "clean_productivity_apps calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_productivity_apps
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"MiaoYan cache"* ]]
[[ "$output" == *"Flomo cache"* ]]
}
@test "clean_screenshot_tools calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_screenshot_tools
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"CleanShot cache"* ]]
[[ "$output" == *"Xnip cache"* ]]
}
@test "clean_office_applications calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/user.sh"
stop_section_spinner() { :; }
safe_clean() { echo "$2"; }
clean_office_applications
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Microsoft Word cache"* ]]
[[ "$output" == *"Apple iWork cache"* ]]
}

32
tests/app_protection.bats Normal file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bats
setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
}
@test "is_critical_system_component matches known system services" {
run bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/app_protection.sh"
is_critical_system_component "backgroundtaskmanagement" && echo "yes"
is_critical_system_component "SystemSettings" && echo "yes"
EOF
[ "$status" -eq 0 ]
[[ "${lines[0]}" == "yes" ]]
[[ "${lines[1]}" == "yes" ]]
}
@test "is_critical_system_component ignores non-system names" {
run bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/app_protection.sh"
if is_critical_system_component "myapp"; then
echo "bad"
else
echo "ok"
fi
EOF
[ "$status" -eq 0 ]
[[ "$output" == "ok" ]]
}

90
tests/apps_module.bats Normal file
View File

@@ -0,0 +1,90 @@
#!/usr/bin/env bats
setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-apps-module.XXXXXX")"
export HOME
mkdir -p "$HOME"
}
teardown_file() {
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
}
@test "clean_ds_store_tree reports dry-run summary" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/apps.sh"
start_inline_spinner() { :; }
stop_section_spinner() { :; }
note_activity() { :; }
get_file_size() { echo 10; }
bytes_to_human() { echo "0B"; }
files_cleaned=0
total_size_cleaned=0
total_items=0
mkdir -p "$HOME/test_ds"
touch "$HOME/test_ds/.DS_Store"
clean_ds_store_tree "$HOME/test_ds" "DS test"
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"DS test"* ]]
}
@test "scan_installed_apps uses cache when fresh" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/apps.sh"
mkdir -p "$HOME/.cache/mole"
echo "com.example.App" > "$HOME/.cache/mole/installed_apps_cache"
get_file_mtime() { date +%s; }
debug_log() { :; }
scan_installed_apps "$HOME/installed.txt"
cat "$HOME/installed.txt"
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"com.example.App"* ]]
}
@test "is_bundle_orphaned returns true for old uninstalled bundle" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" ORPHAN_AGE_THRESHOLD=60 bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/apps.sh"
should_protect_data() { return 1; }
get_file_mtime() { echo 0; }
if is_bundle_orphaned "com.example.Old" "$HOME/old" "$HOME/installed.txt"; then
echo "orphan"
fi
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"orphan"* ]]
}
@test "clean_orphaned_app_data skips when no permission" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/apps.sh"
ls() { return 1; }
stop_section_spinner() { :; }
clean_orphaned_app_data
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Skipped: No permission"* ]]
}

View File

@@ -76,6 +76,7 @@ sudo() {
echo "Installing Rosetta 2 stub output" echo "Installing Rosetta 2 stub output"
return 0 return 0
;; ;;
/usr/libexec/ApplicationFirewall/socketfilterfw) return 0 ;;
*) return 0 ;; *) return 0 ;;
esac esac
} }

View File

@@ -0,0 +1,289 @@
#!/usr/bin/env bats
setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-browser-cleanup.XXXXXX")"
export HOME
mkdir -p "$HOME"
}
teardown_file() {
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
}
@test "clean_chrome_old_versions skips when Chrome is running" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh"
# Mock pgrep to simulate Chrome running
pgrep() { return 0; }
export -f pgrep
clean_chrome_old_versions
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Google Chrome running"* ]]
[[ "$output" == *"old versions cleanup skipped"* ]]
}
@test "clean_chrome_old_versions removes old versions but keeps current" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh"
# Mock pgrep to simulate Chrome not running
pgrep() { return 1; }
export -f pgrep
# Create mock Chrome directory structure
CHROME_APP="$HOME/Applications/Google Chrome.app"
VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions"
mkdir -p "$VERSIONS_DIR"/{128.0.0.0,129.0.0.0,130.0.0.0}
# Create Current symlink pointing to 130.0.0.0
ln -s "130.0.0.0" "$VERSIONS_DIR/Current"
# Mock functions
is_path_whitelisted() { return 1; }
get_path_size_kb() { echo "10240"; }
bytes_to_human() { echo "10M"; }
note_activity() { :; }
export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity
# Initialize counters
files_cleaned=0
total_size_cleaned=0
total_items=0
clean_chrome_old_versions
# Verify output mentions old versions cleanup
echo "Cleaned: $files_cleaned items"
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Chrome old versions"* ]]
[[ "$output" == *"dry"* ]]
[[ "$output" == *"Cleaned: 2 items"* ]]
}
@test "clean_chrome_old_versions respects whitelist" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh"
# Mock pgrep to simulate Chrome not running
pgrep() { return 1; }
export -f pgrep
# Create mock Chrome directory structure
CHROME_APP="$HOME/Applications/Google Chrome.app"
VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions"
mkdir -p "$VERSIONS_DIR"/{128.0.0.0,129.0.0.0,130.0.0.0}
# Create Current symlink pointing to 130.0.0.0
ln -s "130.0.0.0" "$VERSIONS_DIR/Current"
# Mock is_path_whitelisted to protect version 128.0.0.0
is_path_whitelisted() {
[[ "$1" == *"128.0.0.0"* ]] && return 0
return 1
}
get_path_size_kb() { echo "10240"; }
bytes_to_human() { echo "10M"; }
note_activity() { :; }
export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity
# Initialize counters
files_cleaned=0
total_size_cleaned=0
total_items=0
clean_chrome_old_versions
# Should only clean 129.0.0.0 (not 128.0.0.0 which is whitelisted)
echo "Cleaned: $files_cleaned items"
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Cleaned: 1 items"* ]]
}
@test "clean_chrome_old_versions DRY_RUN mode does not delete files" {
# Create test directory
CHROME_APP="$HOME/Applications/Google Chrome.app"
VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions"
mkdir -p "$VERSIONS_DIR"/{128.0.0.0,130.0.0.0}
# Remove Current if it exists as a directory, then create symlink
rm -rf "$VERSIONS_DIR/Current"
ln -s "130.0.0.0" "$VERSIONS_DIR/Current"
# Create a marker file in old version
touch "$VERSIONS_DIR/128.0.0.0/marker.txt"
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh"
pgrep() { return 1; }
is_path_whitelisted() { return 1; }
get_path_size_kb() { echo "10240"; }
bytes_to_human() { echo "10M"; }
note_activity() { :; }
export -f pgrep is_path_whitelisted get_path_size_kb bytes_to_human note_activity
files_cleaned=0
total_size_cleaned=0
total_items=0
clean_chrome_old_versions
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"dry"* ]]
# Verify marker file still exists (not deleted in dry run)
[ -f "$VERSIONS_DIR/128.0.0.0/marker.txt" ]
}
@test "clean_chrome_old_versions handles missing Current symlink gracefully" {
# Use a fresh temp directory for this test
TEST_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-test5.XXXXXX")"
run env HOME="$TEST_HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh"
pgrep() { return 1; }
is_path_whitelisted() { return 1; }
get_path_size_kb() { echo "10240"; }
bytes_to_human() { echo "10M"; }
note_activity() { :; }
export -f pgrep is_path_whitelisted get_path_size_kb bytes_to_human note_activity
# Initialize counters to prevent unbound variable errors
files_cleaned=0
total_size_cleaned=0
total_items=0
# Create Chrome app without Current symlink
CHROME_APP="$HOME/Applications/Google Chrome.app"
VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions"
mkdir -p "$VERSIONS_DIR"/{128.0.0.0,129.0.0.0}
# No Current symlink created
clean_chrome_old_versions
EOF
rm -rf "$TEST_HOME"
[ "$status" -eq 0 ]
# Should exit gracefully with no output
}
@test "clean_edge_old_versions skips when Edge is running" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh"
# Mock pgrep to simulate Edge running
pgrep() { return 0; }
export -f pgrep
clean_edge_old_versions
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Microsoft Edge running"* ]]
[[ "$output" == *"old versions cleanup skipped"* ]]
}
@test "clean_edge_old_versions removes old versions but keeps current" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh"
pgrep() { return 1; }
export -f pgrep
# Create mock Edge directory structure
EDGE_APP="$HOME/Applications/Microsoft Edge.app"
VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions"
mkdir -p "$VERSIONS_DIR"/{120.0.0.0,121.0.0.0,122.0.0.0}
# Create Current symlink pointing to 122.0.0.0
ln -s "122.0.0.0" "$VERSIONS_DIR/Current"
is_path_whitelisted() { return 1; }
get_path_size_kb() { echo "10240"; }
bytes_to_human() { echo "10M"; }
note_activity() { :; }
export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity
files_cleaned=0
total_size_cleaned=0
total_items=0
clean_edge_old_versions
echo "Cleaned: $files_cleaned items"
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Edge old versions"* ]]
[[ "$output" == *"dry"* ]]
[[ "$output" == *"Cleaned: 2 items"* ]]
}
@test "clean_edge_old_versions handles no old versions gracefully" {
# Use a fresh temp directory for this test
TEST_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-test8.XXXXXX")"
run env HOME="$TEST_HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh"
pgrep() { return 1; }
is_path_whitelisted() { return 1; }
get_path_size_kb() { echo "10240"; }
bytes_to_human() { echo "10M"; }
note_activity() { :; }
export -f pgrep is_path_whitelisted get_path_size_kb bytes_to_human note_activity
# Initialize counters
files_cleaned=0
total_size_cleaned=0
total_items=0
# Create Edge with only current version
EDGE_APP="$HOME/Applications/Microsoft Edge.app"
VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions"
mkdir -p "$VERSIONS_DIR/122.0.0.0"
ln -s "122.0.0.0" "$VERSIONS_DIR/Current"
clean_edge_old_versions
EOF
rm -rf "$TEST_HOME"
[ "$status" -eq 0 ]
# Should exit gracefully with no cleanup output
[[ "$output" != *"Edge old versions"* ]]
}

View File

@@ -28,7 +28,7 @@ setup() {
} }
@test "mo clean --dry-run skips system cleanup in non-interactive mode" { @test "mo clean --dry-run skips system cleanup in non-interactive mode" {
run env HOME="$HOME" "$PROJECT_ROOT/mole" clean --dry-run run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean --dry-run
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"Dry Run Mode"* ]] [[ "$output" == *"Dry Run Mode"* ]]
[[ "$output" != *"Deep system-level cleanup"* ]] [[ "$output" != *"Deep system-level cleanup"* ]]
@@ -38,7 +38,7 @@ setup() {
mkdir -p "$HOME/Library/Caches/TestApp" mkdir -p "$HOME/Library/Caches/TestApp"
echo "cache data" > "$HOME/Library/Caches/TestApp/cache.tmp" echo "cache data" > "$HOME/Library/Caches/TestApp/cache.tmp"
run env HOME="$HOME" "$PROJECT_ROOT/mole" clean --dry-run run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean --dry-run
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"User app cache"* ]] [[ "$output" == *"User app cache"* ]]
[[ "$output" == *"Potential space"* ]] [[ "$output" == *"Potential space"* ]]
@@ -53,7 +53,7 @@ setup() {
$HOME/Library/Caches/WhitelistedApp* $HOME/Library/Caches/WhitelistedApp*
EOF EOF
run env HOME="$HOME" "$PROJECT_ROOT/mole" clean --dry-run run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean --dry-run
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"Protected"* ]] [[ "$output" == *"Protected"* ]]
[ -f "$HOME/Library/Caches/WhitelistedApp/data.tmp" ] [ -f "$HOME/Library/Caches/WhitelistedApp/data.tmp" ]
@@ -63,7 +63,7 @@ EOF
mkdir -p "$HOME/.m2/repository/org/example" mkdir -p "$HOME/.m2/repository/org/example"
echo "dependency" > "$HOME/.m2/repository/org/example/lib.jar" echo "dependency" > "$HOME/.m2/repository/org/example/lib.jar"
run env HOME="$HOME" "$PROJECT_ROOT/mole" clean --dry-run run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean --dry-run
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[ -f "$HOME/.m2/repository/org/example/lib.jar" ] [ -f "$HOME/.m2/repository/org/example/lib.jar" ]
[[ "$output" != *"Maven repository cache"* ]] [[ "$output" != *"Maven repository cache"* ]]
@@ -86,8 +86,175 @@ EOF
FINDER_METADATA_SENTINEL FINDER_METADATA_SENTINEL
EOF EOF
run env HOME="$HOME" "$PROJECT_ROOT/mole" clean --dry-run # Test whitelist logic directly instead of running full clean
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/manage/whitelist.sh"
load_whitelist
if is_whitelisted "$HOME/Documents/.DS_Store"; then
echo "protected by whitelist"
fi
EOF
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"protected by whitelist"* ]] [[ "$output" == *"protected by whitelist"* ]]
[ -f "$HOME/Documents/.DS_Store" ] [ -f "$HOME/Documents/.DS_Store" ]
} }
@test "clean_recent_items removes shared file lists" {
local shared_dir="$HOME/Library/Application Support/com.apple.sharedfilelist"
mkdir -p "$shared_dir"
touch "$shared_dir/com.apple.LSSharedFileList.RecentApplications.sfl2"
touch "$shared_dir/com.apple.LSSharedFileList.RecentDocuments.sfl2"
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh"
safe_clean() {
echo "safe_clean $1"
}
clean_recent_items
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Recent"* ]]
}
@test "clean_recent_items handles missing shared directory" {
rm -rf "$HOME/Library/Application Support/com.apple.sharedfilelist"
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh"
safe_clean() {
echo "safe_clean $1"
}
clean_recent_items
EOF
[ "$status" -eq 0 ]
}
@test "clean_mail_downloads skips cleanup when size below threshold" {
mkdir -p "$HOME/Library/Mail Downloads"
echo "test" > "$HOME/Library/Mail Downloads/small.txt"
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh"
clean_mail_downloads
EOF
[ "$status" -eq 0 ]
[ -f "$HOME/Library/Mail Downloads/small.txt" ]
}
@test "clean_mail_downloads removes old attachments" {
mkdir -p "$HOME/Library/Mail Downloads"
touch "$HOME/Library/Mail Downloads/old.pdf"
touch -t 202301010000 "$HOME/Library/Mail Downloads/old.pdf"
dd if=/dev/zero of="$HOME/Library/Mail Downloads/dummy.dat" bs=1024 count=6000 2>/dev/null
[ -f "$HOME/Library/Mail Downloads/old.pdf" ]
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh"
clean_mail_downloads
EOF
[ "$status" -eq 0 ]
[ ! -f "$HOME/Library/Mail Downloads/old.pdf" ]
}
@test "clean_time_machine_failed_backups detects running backup correctly" {
if ! command -v tmutil > /dev/null 2>&1; then
skip "tmutil not available"
fi
local mock_bin="$HOME/bin"
mkdir -p "$mock_bin"
cat > "$mock_bin/tmutil" << 'MOCK_TMUTIL'
#!/bin/bash
if [[ "$1" == "status" ]]; then
cat << 'TMUTIL_OUTPUT'
Backup session status:
{
ClientID = "com.apple.backupd";
Running = 0;
}
TMUTIL_OUTPUT
elif [[ "$1" == "destinationinfo" ]]; then
cat << 'DEST_OUTPUT'
====================================================
Name : TestBackup
Kind : Local
Mount Point : /Volumes/TestBackup
ID : 12345678-1234-1234-1234-123456789012
====================================================
DEST_OUTPUT
fi
MOCK_TMUTIL
chmod +x "$mock_bin/tmutil"
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$mock_bin:$PATH" bash --noprofile --norc << 'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/system.sh"
clean_time_machine_failed_backups
EOF
[ "$status" -eq 0 ]
[[ "$output" != *"Time Machine backup in progress, skipping cleanup"* ]]
}
@test "clean_time_machine_failed_backups skips when backup is actually running" {
if ! command -v tmutil > /dev/null 2>&1; then
skip "tmutil not available"
fi
local mock_bin="$HOME/bin"
mkdir -p "$mock_bin"
cat > "$mock_bin/tmutil" << 'MOCK_TMUTIL'
#!/bin/bash
if [[ "$1" == "status" ]]; then
cat << 'TMUTIL_OUTPUT'
Backup session status:
{
ClientID = "com.apple.backupd";
Running = 1;
}
TMUTIL_OUTPUT
elif [[ "$1" == "destinationinfo" ]]; then
cat << 'DEST_OUTPUT'
====================================================
Name : TestBackup
Kind : Local
Mount Point : /Volumes/TestBackup
ID : 12345678-1234-1234-1234-123456789012
====================================================
DEST_OUTPUT
fi
MOCK_TMUTIL
chmod +x "$mock_bin/tmutil"
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$mock_bin:$PATH" bash --noprofile --norc << 'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/system.sh"
clean_time_machine_failed_backups
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Time Machine backup in progress, skipping cleanup"* ]]
}

View File

@@ -27,77 +27,84 @@ setup() {
source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/caches.sh" source "$PROJECT_ROOT/lib/clean/caches.sh"
# Clean permission flag for each test # Mock run_with_timeout to skip timeout overhead in tests
# shellcheck disable=SC2329
run_with_timeout() {
shift # Remove timeout argument
"$@"
}
export -f run_with_timeout
rm -f "$HOME/.cache/mole/permissions_granted" rm -f "$HOME/.cache/mole/permissions_granted"
} }
# Test check_tcc_permissions in non-interactive mode
@test "check_tcc_permissions skips in non-interactive mode" { @test "check_tcc_permissions skips in non-interactive mode" {
# Redirect stdin to simulate non-TTY
run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; check_tcc_permissions" < /dev/null run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; check_tcc_permissions" < /dev/null
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
# Should not create permission flag in non-interactive mode
[[ ! -f "$HOME/.cache/mole/permissions_granted" ]] [[ ! -f "$HOME/.cache/mole/permissions_granted" ]]
} }
# Test check_tcc_permissions with existing permission flag
@test "check_tcc_permissions skips when permissions already granted" { @test "check_tcc_permissions skips when permissions already granted" {
# Create permission flag
mkdir -p "$HOME/.cache/mole" mkdir -p "$HOME/.cache/mole"
touch "$HOME/.cache/mole/permissions_granted" touch "$HOME/.cache/mole/permissions_granted"
# Even in TTY mode, should skip if flag exists
run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; [[ -t 1 ]] || true; check_tcc_permissions" run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; [[ -t 1 ]] || true; check_tcc_permissions"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
# Test check_tcc_permissions directory checks
@test "check_tcc_permissions validates protected directories" { @test "check_tcc_permissions validates protected directories" {
# The function checks these directories exist:
# - ~/Library/Caches
# - ~/Library/Logs
# - ~/Library/Application Support
# - ~/Library/Containers
# - ~/.cache
# Ensure test environment has these directories
[[ -d "$HOME/Library/Caches" ]] [[ -d "$HOME/Library/Caches" ]]
[[ -d "$HOME/Library/Logs" ]] [[ -d "$HOME/Library/Logs" ]]
[[ -d "$HOME/.cache/mole" ]] [[ -d "$HOME/.cache/mole" ]]
# Function should handle missing directories gracefully
run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; check_tcc_permissions < /dev/null" run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; check_tcc_permissions < /dev/null"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
# Test clean_service_worker_cache with non-existent path
@test "clean_service_worker_cache returns early when path doesn't exist" { @test "clean_service_worker_cache returns early when path doesn't exist" {
run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; clean_service_worker_cache 'TestBrowser' '/nonexistent/path'" run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; clean_service_worker_cache 'TestBrowser' '/nonexistent/path'"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
# Test clean_service_worker_cache with empty directory
@test "clean_service_worker_cache handles empty cache directory" { @test "clean_service_worker_cache handles empty cache directory" {
local test_cache="$HOME/test_sw_cache" local test_cache="$HOME/test_sw_cache"
mkdir -p "$test_cache" mkdir -p "$test_cache"
run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/clean/caches.sh'; clean_service_worker_cache 'TestBrowser' '$test_cache'" run bash -c "
run_with_timeout() { shift; \"\$@\"; }
export -f run_with_timeout
source '$PROJECT_ROOT/lib/core/common.sh'
source '$PROJECT_ROOT/lib/clean/caches.sh'
clean_service_worker_cache 'TestBrowser' '$test_cache'
"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
rm -rf "$test_cache" rm -rf "$test_cache"
} }
# Test clean_service_worker_cache domain protection
@test "clean_service_worker_cache protects specified domains" { @test "clean_service_worker_cache protects specified domains" {
local test_cache="$HOME/test_sw_cache" local test_cache="$HOME/test_sw_cache"
mkdir -p "$test_cache/abc123_https_capcut.com_0" mkdir -p "$test_cache/abc123_https_capcut.com_0"
mkdir -p "$test_cache/def456_https_example.com_0" mkdir -p "$test_cache/def456_https_example.com_0"
# Mock PROTECTED_SW_DOMAINS
export PROTECTED_SW_DOMAINS=("capcut.com" "photopea.com")
# Dry run to check protection logic
run bash -c " run bash -c "
run_with_timeout() {
local timeout=\"\$1\"
shift
if [[ \"\$1\" == \"get_path_size_kb\" ]]; then
echo 0
return 0
fi
if [[ \"\$1\" == \"sh\" ]]; then
printf '%s\n' \
'$test_cache/abc123_https_capcut.com_0' \
'$test_cache/def456_https_example.com_0'
return 0
fi
\"\$@\"
}
export -f run_with_timeout
export DRY_RUN=true export DRY_RUN=true
export PROTECTED_SW_DOMAINS=(capcut.com photopea.com) export PROTECTED_SW_DOMAINS=(capcut.com photopea.com)
source '$PROJECT_ROOT/lib/core/common.sh' source '$PROJECT_ROOT/lib/core/common.sh'
@@ -106,19 +113,15 @@ setup() {
" "
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
# Protected domain directory should still exist
[[ -d "$test_cache/abc123_https_capcut.com_0" ]] [[ -d "$test_cache/abc123_https_capcut.com_0" ]]
rm -rf "$test_cache" rm -rf "$test_cache"
} }
# Test clean_project_caches function
@test "clean_project_caches completes without errors" { @test "clean_project_caches completes without errors" {
# Create test project structures
mkdir -p "$HOME/projects/test-app/.next/cache" mkdir -p "$HOME/projects/test-app/.next/cache"
mkdir -p "$HOME/projects/python-app/__pycache__" mkdir -p "$HOME/projects/python-app/__pycache__"
# Create some dummy cache files
touch "$HOME/projects/test-app/.next/cache/test.cache" touch "$HOME/projects/test-app/.next/cache/test.cache"
touch "$HOME/projects/python-app/__pycache__/module.pyc" touch "$HOME/projects/python-app/__pycache__/module.pyc"
@@ -133,39 +136,30 @@ setup() {
rm -rf "$HOME/projects" rm -rf "$HOME/projects"
} }
# Test clean_project_caches timeout protection
@test "clean_project_caches handles timeout gracefully" { @test "clean_project_caches handles timeout gracefully" {
# Create a test directory structure
mkdir -p "$HOME/test-project/.next" mkdir -p "$HOME/test-project/.next"
# Mock find to simulate slow operation
function find() { function find() {
sleep 2 # Simulate slow find sleep 2 # Simulate slow find
echo "$HOME/test-project/.next" echo "$HOME/test-project/.next"
} }
export -f find export -f find
# Should complete within reasonable time even with slow find
run timeout 15 bash -c " run timeout 15 bash -c "
source '$PROJECT_ROOT/lib/core/common.sh' source '$PROJECT_ROOT/lib/core/common.sh'
source '$PROJECT_ROOT/lib/clean/caches.sh' source '$PROJECT_ROOT/lib/clean/caches.sh'
clean_project_caches clean_project_caches
" "
# Either succeeds or times out gracefully (both acceptable)
[ "$status" -eq 0 ] || [ "$status" -eq 124 ] [ "$status" -eq 0 ] || [ "$status" -eq 124 ]
rm -rf "$HOME/test-project" rm -rf "$HOME/test-project"
} }
# Test clean_project_caches exclusions
@test "clean_project_caches excludes Library and Trash directories" { @test "clean_project_caches excludes Library and Trash directories" {
# These directories should be excluded from scan
mkdir -p "$HOME/Library/.next/cache" mkdir -p "$HOME/Library/.next/cache"
mkdir -p "$HOME/.Trash/.next/cache" mkdir -p "$HOME/.Trash/.next/cache"
mkdir -p "$HOME/projects/.next/cache" mkdir -p "$HOME/projects/.next/cache"
# Only non-excluded directories should be scanned
# We can't easily test this without mocking, but we can verify no crashes
run bash -c " run bash -c "
export DRY_RUN=true export DRY_RUN=true
source '$PROJECT_ROOT/lib/core/common.sh' source '$PROJECT_ROOT/lib/core/common.sh'
@@ -176,4 +170,3 @@ setup() {
rm -rf "$HOME/projects" rm -rf "$HOME/projects"
} }

102
tests/clean_extras.bats Normal file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bats
setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-clean-extras.XXXXXX")"
export HOME
mkdir -p "$HOME"
}
teardown_file() {
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
}
@test "clean_cloud_storage calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh"
stop_section_spinner() { :; }
safe_clean() { echo "$2"; }
clean_cloud_storage
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Dropbox cache"* ]]
[[ "$output" == *"Google Drive cache"* ]]
}
@test "clean_virtualization_tools hits cache paths" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh"
stop_section_spinner() { :; }
safe_clean() { echo "$2"; }
clean_virtualization_tools
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"VMware Fusion cache"* ]]
[[ "$output" == *"Parallels cache"* ]]
}
@test "clean_email_clients calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_email_clients
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Spark cache"* ]]
[[ "$output" == *"Airmail cache"* ]]
}
@test "clean_note_apps calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_note_apps
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Notion cache"* ]]
[[ "$output" == *"Obsidian cache"* ]]
}
@test "clean_task_apps calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_task_apps
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Todoist cache"* ]]
[[ "$output" == *"Any.do cache"* ]]
}
@test "scan_external_volumes skips when no volumes" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/clean/user.sh"
run_with_timeout() { return 1; }
scan_external_volumes
EOF
[ "$status" -eq 0 ]
}

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env bats
setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-clean-extras-more.XXXXXX")"
export HOME
mkdir -p "$HOME"
}
teardown_file() {
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
}
@test "clean_video_tools calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_video_tools
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"ScreenFlow cache"* ]]
[[ "$output" == *"Final Cut Pro cache"* ]]
}
@test "clean_video_players calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_video_players
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"IINA cache"* ]]
[[ "$output" == *"VLC cache"* ]]
}
@test "clean_3d_tools calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_3d_tools
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Blender cache"* ]]
[[ "$output" == *"Cinema 4D cache"* ]]
}
@test "clean_gaming_platforms calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_gaming_platforms
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Steam cache"* ]]
[[ "$output" == *"Epic Games cache"* ]]
}
@test "clean_translation_apps calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_translation_apps
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Youdao Dictionary cache"* ]]
[[ "$output" == *"Eudict cache"* ]]
}
@test "clean_launcher_apps calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_launcher_apps
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Alfred cache"* ]]
[[ "$output" == *"The Unarchiver cache"* ]]
}
@test "clean_remote_desktop calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_remote_desktop
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"TeamViewer cache"* ]]
[[ "$output" == *"AnyDesk cache"* ]]
}
@test "clean_system_utils calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_system_utils
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Input Source Pro cache"* ]]
[[ "$output" == *"WakaTime cache"* ]]
}
@test "clean_shell_utils calls expected caches" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/app_caches.sh"
safe_clean() { echo "$2"; }
clean_shell_utils
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"Zsh completion cache"* ]]
[[ "$output" == *"wget HSTS cache"* ]]
}

View File

@@ -46,24 +46,18 @@ setup() {
} }
@test "touchid status reports current configuration" { @test "touchid status reports current configuration" {
# Don't test actual Touch ID config (system-dependent, may trigger prompts)
# Just verify the command exists and can run
run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
# Should output either "enabled" or "not configured" message
[[ "$output" == *"Touch ID"* ]] [[ "$output" == *"Touch ID"* ]]
} }
@test "mo optimize command is recognized" { @test "mo optimize command is recognized" {
# Test that optimize command exists without actually running it
# Running full optimize in tests is too slow (waits for sudo, runs health checks)
run bash -c "grep -q '\"optimize\")' '$PROJECT_ROOT/mole'" run bash -c "grep -q '\"optimize\")' '$PROJECT_ROOT/mole'"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "mo analyze binary is valid" { @test "mo analyze binary is valid" {
if [[ -f "$PROJECT_ROOT/bin/analyze-go" ]]; then if [[ -f "$PROJECT_ROOT/bin/analyze-go" ]]; then
# Verify binary is executable and valid Universal Binary
[ -x "$PROJECT_ROOT/bin/analyze-go" ] [ -x "$PROJECT_ROOT/bin/analyze-go" ]
run file "$PROJECT_ROOT/bin/analyze-go" run file "$PROJECT_ROOT/bin/analyze-go"
[[ "$output" == *"Mach-O"* ]] || [[ "$output" == *"executable"* ]] [[ "$output" == *"Mach-O"* ]] || [[ "$output" == *"executable"* ]]

Some files were not shown because too many files have changed in this diff Show More